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:
126
app/web/labels.py
Normal file
126
app/web/labels.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
|
||||
|
||||
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
||||
|
||||
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tipul returnat: (text_principal, subtext_tooltip, css_class)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Eticheta = Tuple[str, str, str]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete stari submissions
|
||||
# Clasele CSS corespund celor definite in base.html:
|
||||
# s-queued (accent/albastru), s-sending (warn/galben), s-sent (ok/verde),
|
||||
# s-error, s-needs_data, s-needs_mapping (err/rosu).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STARI_SUBMISSION: dict[str, Eticheta] = {
|
||||
"queued": (
|
||||
"In asteptare sa fie trimise",
|
||||
"",
|
||||
"s-queued",
|
||||
),
|
||||
"sending": (
|
||||
"Se trimite acum",
|
||||
"",
|
||||
"s-sending",
|
||||
),
|
||||
"sent": (
|
||||
"Declarate la RAR (finalizate)",
|
||||
"Confirmate cu numar de prezentare; nu se mai pot modifica.",
|
||||
"s-sent",
|
||||
),
|
||||
"needs_mapping": (
|
||||
"Lipseste codul prestatiei",
|
||||
"Alege codul RAR in tab-ul Mapari.",
|
||||
"s-needs_mapping",
|
||||
),
|
||||
"needs_data": (
|
||||
"Date incomplete (respinse de RAR)",
|
||||
"Corecteaza randul si reimporta.",
|
||||
"s-needs_data",
|
||||
),
|
||||
"error": (
|
||||
"Eroare la trimitere",
|
||||
"Vezi detaliul randului; se reincearca automat sau necesita corectie.",
|
||||
"s-error",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def eticheta_stare(status: str) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru o stare de submission.
|
||||
|
||||
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||
"""
|
||||
try:
|
||||
return STARI_SUBMISSION[status]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"Starea de submission {status!r} nu are eticheta umana in labels.py. "
|
||||
"Adauga-o in STARI_SUBMISSION."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete worker (viu / mort)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_worker(viu: bool) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea worker-ului.
|
||||
|
||||
viu=True => "Trimitere automata: activa" (clasa s-sent / verde)
|
||||
viu=False => "Trimitere automata: oprita" (clasa s-error / rosu)
|
||||
"""
|
||||
if viu:
|
||||
return (
|
||||
"Trimitere automata: activa",
|
||||
"Sistemul verifica coada si trimite la RAR la fiecare cateva secunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Trimitere automata: oprita",
|
||||
"Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete conexiune RAR (ok / indisponibil)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_rar(stare: str) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea conexiunii cu RAR.
|
||||
|
||||
stare="ok" => "Legatura cu RAR: functionala" (s-sent / verde)
|
||||
stare="indisponibil" => "Legatura cu RAR: indisponibila" (s-error / rosu)
|
||||
"""
|
||||
if stare == "ok":
|
||||
return (
|
||||
"Legatura cu RAR: functionala",
|
||||
"Portalul AUTOPASS raspunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Legatura cu RAR: indisponibila",
|
||||
"Portalul RAR nu raspunde acum; coada se reia automat cand revine.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constante auxiliare (microcopy fix, fara logica)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR = "Ultima autentificare la RAR"
|
||||
@@ -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)
|
||||
|
||||
81
app/web/templates/_acasa.html
Normal file
81
app/web/templates/_acasa.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<div id="acasa-section">
|
||||
|
||||
{% set toti_esentiali = are_creds and are_trimiteri %}
|
||||
|
||||
{% if toti_esentiali %}
|
||||
{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #}
|
||||
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
|
||||
Totul e configurat —
|
||||
<a href="/?tab=coada">vezi coada</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
|
||||
|
||||
{# Pas 1: Conecteaza contul RAR (esential) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
{% if are_creds %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
Email + parola portal AUTOPASS RAR
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{# Pas 2: Cheie API (optional) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
{% if are_cheie_folosita %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=cont">Ia-ti cheia API</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
<em>Optional</em> — doar daca trimiti din soft propriu prin API
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{# Pas 3: Import primul fisier (esential) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
{% if are_trimiteri %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=import">Importa primul fisier</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
|
||||
<p class="muted" style="margin:0 0 10px; font-size:13px;">
|
||||
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
|
||||
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
|
||||
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
|
||||
</p>
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
|
||||
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
|
||||
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
|
||||
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
14
app/web/templates/_coada.html
Normal file
14
app/web/templates/_coada.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div id="coada-section">
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
|
||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</span>
|
||||
</div>
|
||||
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,10 @@
|
||||
{% endif %}
|
||||
|
||||
{% if not pending %}
|
||||
<div class="empty">Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.</div>
|
||||
<div class="empty">
|
||||
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
|
||||
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div id="import-section">
|
||||
{% set pas = 2 %}{% include '_stepper.html' %}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div id="import-section">
|
||||
{% set pas = 3 %}{% include '_stepper.html' %}
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
|
||||
<h2 style="font-size:15px; margin:0;">
|
||||
|
||||
80
app/web/templates/_status.html
Normal file
80
app/web/templates/_status.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status"
|
||||
hx-trigger="every 15s"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
|
||||
{% if not account_active %}
|
||||
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
|
||||
background:#201c0f; border-radius:6px; font-size:13px;">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
|
||||
porneste automat dupa activare de catre administrator.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
|
||||
|
||||
<!-- Starea worker (Trimitere automata) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
|
||||
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
|
||||
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
|
||||
</div>
|
||||
{% if worker_lbl[1] %}
|
||||
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Legatura RAR -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">Legatura RAR</div>
|
||||
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
|
||||
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
|
||||
</div>
|
||||
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
|
||||
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ultima autentificare RAR -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
|
||||
<div>{{ last_login or '—' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- In asteptare (queued) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">In asteptare</div>
|
||||
<div class="s-queued">{{ counts_queued }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Declarate la RAR (sent) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
|
||||
<div class="s-sent">{{ counts_sent }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Defalcare blocate pe motiv (doar daca exista) -->
|
||||
{% if blocate_defalcat %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap;">
|
||||
{% for eticheta, n in blocate_defalcat %}
|
||||
{% if n > 0 %}
|
||||
<div>
|
||||
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
|
||||
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
|
||||
{% if eticheta[1] %}
|
||||
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
96
app/web/templates/_stepper.html
Normal file
96
app/web/templates/_stepper.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{#
|
||||
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare).
|
||||
|
||||
Parametru: `pas` (integer 1-4) — pasul curent.
|
||||
Utilizare in template-uri care mostenesc contextul Jinja2:
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
sau cu `with`:
|
||||
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
|
||||
|
||||
Cei 4 pasi ficsi:
|
||||
1. Incarca fisier
|
||||
2. Potriveste coloanele
|
||||
3. Verifica
|
||||
4. Confirma trimiterea
|
||||
|
||||
Stari vizuale:
|
||||
- index < pas → "facut" (bulina plina, text bifat)
|
||||
- index == pas → "activ" (evidentiat, aria-current="step")
|
||||
- index > pas → "viitor" (estompat)
|
||||
#}
|
||||
{%- set _pasi_import = [
|
||||
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
|
||||
(2, "Potriveste coloanele", "Spune-ne ce coloana din fisier corespunde cu ce camp RAR."),
|
||||
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
|
||||
(4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."),
|
||||
] -%}
|
||||
<nav class="stepper-import" aria-label="Pasii importului" style="
|
||||
display:flex;
|
||||
gap:0;
|
||||
align-items:stretch;
|
||||
margin-bottom:20px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:8px;
|
||||
overflow:hidden;
|
||||
background:var(--card);
|
||||
">
|
||||
{% for nr, titlu, ajutor in _pasi_import %}
|
||||
{%- if nr < pas %}
|
||||
{%- set cls = "facut" -%}
|
||||
{%- set aria = "" -%}
|
||||
{%- elif nr == pas %}
|
||||
{%- set cls = "activ" -%}
|
||||
{%- set aria = ' aria-current="step"' -%}
|
||||
{%- else %}
|
||||
{%- set cls = "viitor" -%}
|
||||
{%- set aria = "" -%}
|
||||
{%- endif %}
|
||||
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
|
||||
style="
|
||||
flex:1;
|
||||
padding:10px 14px;
|
||||
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
|
||||
{% if cls == 'activ' %}
|
||||
background:rgba(91,141,239,.10);
|
||||
{% elif cls == 'facut' %}
|
||||
opacity:1;
|
||||
{% else %}
|
||||
opacity:.4;
|
||||
{% endif %}
|
||||
">
|
||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
|
||||
<span class="stepper-nr" style="
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:20px;
|
||||
height:20px;
|
||||
border-radius:50%;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
flex-shrink:0;
|
||||
{% if cls == 'activ' %}
|
||||
background:var(--accent);
|
||||
color:#fff;
|
||||
{% elif cls == 'facut' %}
|
||||
background:var(--ok);
|
||||
color:#fff;
|
||||
{% else %}
|
||||
background:var(--line);
|
||||
color:var(--muted);
|
||||
{% endif %}
|
||||
">
|
||||
{% if cls == 'facut' %}✓{% else %}{{ nr }}{% endif %}
|
||||
</span>
|
||||
<span style="
|
||||
font-size:13px;
|
||||
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
|
||||
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
|
||||
">{{ titlu }}</span>
|
||||
</div>
|
||||
{% if cls == 'activ' %}
|
||||
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
@@ -18,5 +18,9 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">Coada e goala. Trimite o prezentare prin <code>POST /v1/prezentari</code>.</div>
|
||||
<div class="empty">
|
||||
Nicio trimitere inca —
|
||||
<a href="/?tab=import">incepe cu Import</a>
|
||||
sau trimite o prezentare prin API.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div id="import-section">
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2>
|
||||
|
||||
|
||||
@@ -64,6 +64,20 @@
|
||||
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.08); }
|
||||
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||||
/* Tab-bar (US-003) */
|
||||
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
|
||||
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
|
||||
scrollbar-width:none; }
|
||||
.tab-bar::-webkit-scrollbar { display:none; }
|
||||
.tab-link { display:inline-flex; align-items:center; padding:8px 16px; font-size:14px;
|
||||
font-weight:500; color:var(--muted); text-decoration:none; border-radius:6px 6px 0 0;
|
||||
border:1px solid transparent; border-bottom:none; white-space:nowrap;
|
||||
transition:color .12s, background .12s; margin-bottom:-1px; }
|
||||
.tab-link:hover { color:var(--ink); background:var(--line); }
|
||||
.tab-link.tab-activ { color:var(--ink); background:var(--card);
|
||||
border-color:var(--line); border-bottom-color:var(--card); }
|
||||
.tab-panel { min-height:120px; }
|
||||
.status-bar { margin-bottom:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -10,61 +10,83 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
|
||||
{% include '_upload.html' %}
|
||||
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
Plasa de siguranta pe pene RAR > 30h. Verifica coada mai jos.
|
||||
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status"
|
||||
hx-trigger="load, every 15s"
|
||||
hx-swap="outerHTML">
|
||||
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; gap:24px; flex-wrap:wrap;">
|
||||
<div><div class="muted">Worker</div><div class="{{ 's-sent' if worker_alive else 's-error' }}">
|
||||
{{ 'viu' if worker_alive else 'mort' }}</div></div>
|
||||
<div><div class="muted">RAR</div><div class="{{ 's-sent' if rar_state == 'ok' else 's-error' if 'indisponibil' in rar_state else 'muted' }}">{{ rar_state }}</div></div>
|
||||
<div><div class="muted">Ultimul login RAR</div><div>{{ last_login or '—' }}</div></div>
|
||||
<div><div class="muted">In coada</div><div>{{ counts.get('queued', 0) }}</div></div>
|
||||
<div><div class="muted">Trimise</div><div class="s-sent">{{ counts.get('sent', 0) }}</div></div>
|
||||
<div><div class="muted">Blocate</div><div class="{{ 's-error' if blocked else '' }}">{{ blocked }}</div></div>
|
||||
</div>
|
||||
{% if rar_state != 'ok' %}
|
||||
<p class="muted" style="margin:12px 0 0; font-size:12px;">
|
||||
RAR posibil indisponibil — coada de mai jos arata ultima stare cunoscuta (local), nu live din RAR.
|
||||
</p>
|
||||
{% endif %}
|
||||
<!-- Tab-bar: navigare intre sectiuni -->
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{% set tabs = [
|
||||
("acasa", "Acasa", "tab-acasa"),
|
||||
("import", "Import", "tab-import"),
|
||||
("coada", "Coada", "tab-coada"),
|
||||
("mapari", "Mapari", "tab-mapari"),
|
||||
("cont", "Cont", "tab-cont"),
|
||||
("nomenclator", "Nomenclator", "tab-nomenclator")
|
||||
] %}
|
||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||
<a id="{{ tab_elem_id }}"
|
||||
role="tab"
|
||||
href="/?tab={{ tab_id }}"
|
||||
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
|
||||
aria-controls="tab-panel"
|
||||
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
|
||||
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
|
||||
hx-get="/_fragments/{{ tab_id }}"
|
||||
hx-target="#tab-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- incarcat o data; NU poll (sa nu stergem o selectie in curs). Se re-randeaza la salvare. -->
|
||||
<div hx-get="/_fragments/mapari" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card"><div class="empty">se incarca mapari…</div></div>
|
||||
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
|
||||
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
|
||||
{{ panel_html | safe }}
|
||||
</div>
|
||||
|
||||
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card"><div class="empty">se incarca cont…</div></div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
/* Navigare cu sageti intre tab-uri (ARIA pattern) */
|
||||
var tablist = document.querySelector('[role="tablist"]');
|
||||
if (!tablist) return;
|
||||
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
||||
tablist.addEventListener('keydown', function(e) {
|
||||
var idx = tabs.indexOf(document.activeElement);
|
||||
if (idx === -1) return;
|
||||
var next = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
next = (idx + 1) % tabs.length;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
next = (idx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
next = 0;
|
||||
} else if (e.key === 'End') {
|
||||
next = tabs.length - 1;
|
||||
}
|
||||
if (next !== -1) {
|
||||
e.preventDefault();
|
||||
tabs[next].focus();
|
||||
}
|
||||
});
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
|
||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</span>
|
||||
</div>
|
||||
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<details>
|
||||
<summary style="cursor:pointer; font-size:15px; font-weight:600;">Nomenclator RAR (coduri prestatii)</summary>
|
||||
<div style="margin-top:12px;" hx-get="/_fragments/nomenclator" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
/* La click pe tab: actualizeaza aria-selected + tabindex */
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) {
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
t.setAttribute('tabindex', '-1');
|
||||
t.classList.remove('tab-activ');
|
||||
});
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
tab.setAttribute('tabindex', '0');
|
||||
tab.classList.add('tab-activ');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
|
||||
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
|
||||
|
||||
**Ultima actualizare**: 2026-06-18 — 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
|
||||
**Ultima actualizare**: 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP).
|
||||
|
||||
> 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
|
||||
|
||||
> 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login`→`LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`.
|
||||
|
||||
@@ -78,7 +80,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
| 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
|
||||
| 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
| 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | TODO | | Reorganizare dashboard: tab-uri sus (Acasa/Import/Mapari/Cont/Nomenclator), import ca stepper 4 pasi, ghid de pornire auto-bifat, etichete umane (`labels.py`) in loc de "worker viu". Doar stratul de prezentare (Jinja2+HTMX), fara backend de trimitere. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) |
|
||||
| 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | DONE | 2026-06-18 | Dashboard reorganizat in 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=` + panou activ server-side + lazy pe rest; bara status cu etichete umane (`app/web/labels.py`) + defalcare blocate; import ca stepper 4 pasi (PUR vizual); Acasa onboarding auto-bifat + empty states. Backend trimitere neatins. 434 teste. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) |
|
||||
|
||||
### Etapa 4 — Viitor (Treapta 3)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 3.4 — Interfata web ergonomica (tab-uri + wizard + microcopy uman)
|
||||
|
||||
**Stare**: aprobat
|
||||
**Stare**: inchis
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
@@ -75,9 +75,9 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
|
||||
| `error` | **Eroare la trimitere** | Vezi detaliul randului; se reincearca automat sau necesita corectie. |
|
||||
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`.
|
||||
- [ ] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata).
|
||||
- [ ] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
|
||||
- [x] `labels.py` expune o functie/dict care, pentru fiecare stare din `schema.sql`, da `(text, css_class)`.
|
||||
- [x] Nicio stare de submission existenta nu ramane fara eticheta (test parametrizat care iese rosu daca se adauga o stare noua nemapata).
|
||||
- [x] Functiile sunt pure (fara DB, fara request) — usor de testat unitar.
|
||||
- **Verificare E2E**: indirect, prin US-002/US-003 (etichetele apar in UI).
|
||||
|
||||
### US-002: Bara de status persistenta cu etichete umane (fragment)
|
||||
@@ -94,9 +94,9 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
|
||||
`labels.py`. Ramane sticky/vizibil sus indiferent de tab-ul activ. Defalca "Necesita atentia ta"
|
||||
pe motive. Pastreaza poll-ul HTMX (`every 15s`) deja existent pentru banner.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI).
|
||||
- [ ] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ).
|
||||
- [ ] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar.
|
||||
- [x] `/_fragments/status` randeaza bara cu etichetele din US-001 (scoped pe cont, ca restul UI).
|
||||
- [x] Bara ramane vizibila sus cand se schimba tab-ul (nu e inghitita de panoul activ).
|
||||
- [x] Cand exista submissions blocate, bara arata defalcarea pe motiv, nu doar un numar.
|
||||
- **Verificare E2E**: browser — incarca `/`, bara de status arata text uman; opreste workerul →
|
||||
"Trimitere automata: oprita".
|
||||
|
||||
@@ -122,12 +122,12 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
|
||||
`role="tabpanel"` pe panou, navigare cu sageti intre tab-uri (JS vanilla minim). Mobil: tab-bar se
|
||||
ruleaza orizontal / se sparge curat (fara meniu hamburger — pastram simplu).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare.
|
||||
- [ ] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului.
|
||||
- [ ] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS.
|
||||
- [ ] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil).
|
||||
- [ ] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`).
|
||||
- [ ] Toate functiile existente raman accesibile dintr-un tab (nimic pierdut fata de pagina veche).
|
||||
- [x] Tab-bar cu cele 6 tab-uri (Acasa · Import · Coada · Mapari · Cont · Nomenclator); "Acasa" implicit la prima incarcare.
|
||||
- [x] Un singur panou randat la un moment dat; celelalte fragmente NU se cer pana la activarea tab-ului.
|
||||
- [x] Panoul activ (inclusiv din `?tab=`) e randat **server-side** la full load — fara palpaire la refresh, vizibil si fara JS.
|
||||
- [x] Accesibilitate: `role=tablist/tab/tabpanel`, `aria-selected` pe tab-ul activ, navigare cu sageti (nu doar focus vizibil).
|
||||
- [x] Refresh pe un tab non-implicit revine pe acelasi tab (deep-link prin query string `?tab=`).
|
||||
- [x] Toate functiile existente raman accesibile dintr-un tab (nimic pierdut fata de pagina veche).
|
||||
- **Verificare E2E**: browser — click pe fiecare tab incarca panoul corect; refresh pe `?tab=import`
|
||||
ramane pe Import; navigare cu sageti intre tab-uri functioneaza (citior de ecran anunta tab-ul activ).
|
||||
|
||||
@@ -154,11 +154,11 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
|
||||
panoului de tab (nu vechiul container de la radacina paginii), iar `csrf_token` din formularele de
|
||||
import trebuie sa ramana corect. Verificat prin testele de mai sus + regula de aur.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
|
||||
- [ ] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati.
|
||||
- [ ] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica).
|
||||
- [ ] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare.
|
||||
- [ ] Fluxul de import functioneaza identic (upload → mapare → preview → confirma) — fara regresie.
|
||||
- [x] Acelasi stepper apare in upload, mapare-coloane si preview, cu pasul corect evidentiat.
|
||||
- [x] Pasii deja parcursi sunt marcati ca facuti; cei viitori sunt estompati.
|
||||
- [x] Fiecare pas are un titlu-actiune + o fraza scurta de ajutor (microcopy din US-001 unde se aplica).
|
||||
- [x] `hx-target` din fragmentele de import se rezolva in panoul de tab; `csrf_token` pastrat in formulare.
|
||||
- [x] Fluxul de import functioneaza identic (upload → mapare → preview → confirma) — fara regresie.
|
||||
- **Verificare E2E**: browser — urca `test_data.csv`, parcurge cei 4 pasi; stepper-ul avanseaza corect;
|
||||
commit → randuri in coada → worker → FINALIZATA la RAR test (regula de aur).
|
||||
|
||||
@@ -182,11 +182,11 @@ de "pagini ca un wizard, intuitive" si "caption-uri utile, relevante, simple, pe
|
||||
discreta ("Totul e configurat — vezi coada"), ca sa nu deranjeze utilizatorul experimentat. Sub ghid,
|
||||
pe acelasi tab, un rezumat scurt + scurtaturi (coada recenta / actiuni rapide).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat.
|
||||
- [ ] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission.
|
||||
- [ ] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul).
|
||||
- [ ] Link-urile din ghid duc la tab-ul corect (Cont / Import).
|
||||
- [ ] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la
|
||||
- [x] Pasul "Conecteaza contul RAR" e nebifat fara creds, bifat cand `are_creds` e adevarat.
|
||||
- [x] Pasul "Importa primul fisier" se bifeaza cand contul are cel putin un submission.
|
||||
- [x] Cand toti pasii esentiali sunt gata, ghidul e colapsat/discret (nu ocupa tot ecranul).
|
||||
- [x] Link-urile din ghid duc la tab-ul corect (Cont / Import).
|
||||
- [x] **Empty states prietenoase**: tab Coada gol → "Nicio trimitere inca — incepe cu Import" (link la
|
||||
tab Import); tab Mapari gol → mesaj scurt + indemn, nu un tabel/lista goala fara context.
|
||||
- **Verificare E2E**: browser — cont nou (fara creds): ghid vizibil cu pasi nebifati + tab Coada arata
|
||||
empty state cu indemn la Import; dupa setarea credentialelor si un import, pasii se bifeaza si ghidul se restrange.
|
||||
@@ -268,6 +268,46 @@ Val 4: [US-004] [US-005] ← ambele depind de shell-ul de tab-uri (US-003
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E browser pe `http://localhost:8000/`,
|
||||
> plus regula de aur: import → worker → FINALIZATA la RAR test). Lipseste pana la VERIFY.
|
||||
> Verificare condusa de lead (utilizatorul a respins E2E cu browser/server). Acoperire: suita
|
||||
> pytest completa + verificare ACs prin FastAPI TestClient + spot-check de integrare. E2E cu browser
|
||||
> live si regula de aur LIVE (FINALIZATA la RAR test) NU au fost rulate in aceasta sesiune — vezi nota.
|
||||
|
||||
**Suita**: `python3 -m pytest -q` → **434 passed** (de la 400 baseline: +34 teste noi 3.4). Fara regresie.
|
||||
|
||||
### PASS/FAIL per story
|
||||
|
||||
- **US-001 (labels.py)** — PASS. `tests/test_web_labels.py` (11 teste). `test_toate_starile_au_eticheta`
|
||||
parseaza CHECK-ul din `schema.sql` → iese rosu la stare noua nemapata. Functii pure (fara DB/request).
|
||||
- **US-002 (bara status)** — PASS. `tests/test_web_status_fragment.py`. `/_fragments/status` randeaza
|
||||
"Trimitere automata" (nu "worker viu"), defalcare blocate pe motiv, poll `every 15s`, scoped pe cont.
|
||||
- **US-003 (tab-uri)** — PASS. `tests/test_web_tabs.py` (6 teste). TestClient: `role="tablist"` + 6 tab-uri,
|
||||
Acasa implicit (`aria-selected="true"`), `/?tab=import` randeaza `#import-section` server-side, panou
|
||||
activ in HTML initial, role=tab/tabpanel + aria-selected, navigare cu sageti (JS vanilla). Fragmentele
|
||||
inactive NU se cer la load (swap pe click). Deep-link `?tab=` supravietuieste refresh-ului.
|
||||
- **US-004 (stepper)** — PASS. `tests/test_web_import_stepper.py` (8 teste). Stepper 4 pasi in
|
||||
upload(1)/mapcoloane(2)/preview(3), pasii facuti marcati, `aria-current="step"` pe activ, `hx-target="#import-section"`
|
||||
si `csrf_token` pastrate. Fluxul import (upload→mapare→preview→confirma) neatins — endpointuri intacte.
|
||||
- **US-005 (Acasa onboarding)** — PASS. `tests/test_web_onboarding.py` (6 teste). Checklist auto-bifat
|
||||
(are_creds/are_trimiteri), ghid colapsat cand totul gata, linkuri `?tab=cont`/`?tab=import`, empty states
|
||||
prietenoase pe Coada (indemn Import) si Mapari, scoped pe cont.
|
||||
|
||||
### Regula de aur (regresie)
|
||||
Backend de trimitere (worker, mapping, idempotency, import_router, masina de stari) **neatins** — confirmat
|
||||
prin diff. Fluxul de import pana la enqueue (`queued`) ramane verde prin `tests/test_import_ui.py` +
|
||||
`tests/test_import_e2e.py`. **Trimiterea LIVE la RAR test (FINALIZATA) NU a fost probata in aceasta sesiune**
|
||||
(fara browser/creds RAR test) — recomandata o probare manuala `./start.sh test both --send` inainte de prod.
|
||||
|
||||
### Defecte gasite si reparate in cursul VERIFY/CLOSE
|
||||
1. **Izolare teste (429)**: fixturile noi nu reseteau rate-limiterul de login in-proces (`ratelimit._hits`);
|
||||
rulate impreuna, login-urile depaseau pragul → 429 → 5 teste rosii la rulare subset. Reparat: `ratelimit._hits.clear()`
|
||||
in fixturile celor 4 fisiere noi (pattern din `test_web_login.py`). Suita completa trecea din noroc de ordine.
|
||||
2. **Regresie UX** (code-review): avertismentul "Cont in asteptare de activare" (vechiul `_banner.html`) nu
|
||||
mai era afisat dupa scoaterea `/_fragments/banner`. Re-introdus in bara de status (`account_active`).
|
||||
3. **Consistenta tema**: culori hardcodate in `_acasa.html` → variabile paletei (`--muted`/`--accent`/`--ok`).
|
||||
|
||||
### Cleanup notat, neremediat (non-blocant)
|
||||
- Duplicare intre `_render_panel_{mapari,cont,nomenclator}` si endpointurile fragment existente.
|
||||
- `/_fragments/banner` + `_banner.html` raman dead code dupa mutarea avertismentelor in bara de status.
|
||||
- Ramura moarta in `_render_panel_for_tab` (fallback acasa fara conn) — inaccesibila (tab pre-validat).
|
||||
- Bara de status e lazy (HTMX `load`), nu server-side: fara JS arata "se incarca…". AC formale indeplinite
|
||||
(panoul activ e server-side); de reconsiderat la o iteratie viitoare daca conteaza no-JS pe status.
|
||||
|
||||
@@ -47,9 +47,17 @@ def _body(**over):
|
||||
def test_dashboard_renders_with_rar_state(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# worker neavand heartbeat -> stare RAR necunoscuta (worker oprit)
|
||||
assert "worker oprit" in r.text
|
||||
assert "Nomenclator RAR" in r.text
|
||||
# Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
|
||||
assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
|
||||
# Fragmentul de status contine starea worker (eticheta umana, nu "worker oprit" brut)
|
||||
rs = client.get("/_fragments/status")
|
||||
assert rs.status_code == 200
|
||||
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita"
|
||||
assert "oprita" in rs.text or "Trimitere automata" in rs.text
|
||||
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator
|
||||
rn = client.get("/_fragments/nomenclator")
|
||||
assert rn.status_code == 200
|
||||
assert "Nomenclator" in rn.text or "Cod" in rn.text or "OE-1" in rn.text
|
||||
|
||||
|
||||
def test_nomenclator_fragment_lists_seed(client):
|
||||
@@ -63,7 +71,8 @@ def test_nomenclator_fragment_lists_seed(client):
|
||||
def test_submissions_fragment_empty_state(client):
|
||||
r = client.get("/_fragments/submissions")
|
||||
assert r.status_code == 200
|
||||
assert "Coada e goala" in r.text
|
||||
# US-005: empty state prietenos cu indemn la Import (nu mesajul tehnic vechi)
|
||||
assert "Nicio trimitere" in r.text or "incepe cu Import" in r.text or "?tab=import" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -102,8 +102,11 @@ def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_dashboard_contine_drop_zone(client):
|
||||
"""Dashboard-ul randeaza sectiunea de upload cu drop zone si mesaj warmth."""
|
||||
r = client.get("/")
|
||||
"""Tab-ul Import randeaza sectiunea de upload cu drop zone si mesaj warmth.
|
||||
|
||||
Dupa US-003 sectiunea de import e in tab-ul Import (?tab=import), nu pe pagina principala.
|
||||
"""
|
||||
r = client.get("/?tab=import")
|
||||
assert r.status_code == 200
|
||||
assert "Primul fisier" in r.text
|
||||
assert "drop-zone" in r.text
|
||||
|
||||
293
tests/test_web_import_stepper.py
Normal file
293
tests/test_web_import_stepper.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Teste US-004: wizard import cu stepper vizual (4 pasi numerotati).
|
||||
|
||||
TDD — testele sunt scrise INAINTE de implementare (RED), apoi se face GREEN.
|
||||
|
||||
Verifica:
|
||||
- Pasul 1 activ (aria-current="step") in fragmentul de upload
|
||||
- Pasul 2 activ in fragmentul mapare-coloane
|
||||
- Pasul 3 activ in preview
|
||||
- Pasii 1 si 2 marcati ca "facuti" in preview (clasa/marcaj)
|
||||
- hx-target="#import-section" pastrat in fragmentele de import
|
||||
- csrf_token prezent in formularele de import
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
|
||||
import csv
|
||||
|
||||
buf = io.StringIO()
|
||||
if not rows:
|
||||
return b""
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _make_xlsx_bytes(rows: list[dict]) -> bytes:
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if not rows:
|
||||
return b""
|
||||
headers = list(rows[0].keys())
|
||||
ws.append(headers)
|
||||
for row in rows:
|
||||
ws.append([row.get(h) for h in headers])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_SAMPLE_ROWS = [
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000123",
|
||||
"Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026",
|
||||
"Odometru final": "123456",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000456",
|
||||
"Nr inmatriculare": "B002TST",
|
||||
"Data prestatie": "16.06.2026",
|
||||
"Odometru final": "200000",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -> None:
|
||||
client.post("/v1/mapari", json={
|
||||
"cod_op_service": cod_op,
|
||||
"cod_prestatie": cod_prest,
|
||||
"auto_send": True,
|
||||
})
|
||||
|
||||
|
||||
def _upload_and_get_import_id(client, rows=None) -> int:
|
||||
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.xlsx", xlsx,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||
assert m, f"Nu s-a gasit import_id in raspuns: {r.text[:500]}"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def _get_preview_via_mapare(client, import_id: int) -> str:
|
||||
"""Salveaza maparea de coloane si returneaza textul raspunsului preview."""
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
return r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-004 Teste stepper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stepper_pas1_la_upload(client):
|
||||
"""Fragmentul de upload contine stepper-ul cu pasul 1 activ.
|
||||
|
||||
Verifica prezenta marcajului aria-current='step' pe pasul 'Incarca fisier'
|
||||
sau clasa activa asociata pasului 1.
|
||||
"""
|
||||
r = client.get("/_import/reset")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Stepper-ul trebuie sa fie prezent
|
||||
assert "stepper" in text or "pasi-import" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in fragmentul de upload"
|
||||
# Pasul 1 trebuie sa aiba aria-current="step"
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in fragmentul de upload (pasul 1)"
|
||||
# Textul pasului 1 trebuie sa fie prezent
|
||||
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit"
|
||||
|
||||
|
||||
def test_stepper_pas1_via_tab_import(client):
|
||||
"""Accesand /?tab=import, panoul contine stepper cu pasul 1 activ."""
|
||||
r = client.get("/?tab=import")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in panoul Import (/?tab=import)"
|
||||
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit in panoul Import"
|
||||
|
||||
|
||||
def test_stepper_pas2_la_mapare(client):
|
||||
"""Fragmentul mapare-coloane contine stepper cu pasul 2 activ.
|
||||
|
||||
Declanseaza un upload cu coloane NEMAPATE ca sa primesti _mapcoloane.html.
|
||||
"""
|
||||
# Upload fara mapare salvata → trebuie sa vina _mapcoloane.html
|
||||
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", csv_bytes, "text/csv")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Trebuie sa fie formularul de mapare coloane
|
||||
assert "mapare-coloane" in text, "Nu s-a primit fragmentul de mapare coloane"
|
||||
# Stepper prezent
|
||||
assert "stepper" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in fragmentul mapare-coloane"
|
||||
# Pasul 2 trebuie sa aiba aria-current="step" cu textul "Potriveste"
|
||||
# (pasul 1 e facut, pasul 2 e activ)
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in fragmentul mapare-coloane (pasul 2)"
|
||||
assert "Potriveste" in text, "Textul pasului 2 'Potriveste' nu a fost gasit"
|
||||
|
||||
|
||||
def test_stepper_pas3_la_preview(client):
|
||||
"""Preview contine stepper cu pasul 3 activ.
|
||||
|
||||
Declanseaza upload + salvare mapare → se ajunge la preview.
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
text = _get_preview_via_mapare(client, import_id)
|
||||
|
||||
# Preview trebuie sa fie prezent
|
||||
assert "Preview" in text or "confirm-form" in text, \
|
||||
"Nu s-a primit fragmentul de preview"
|
||||
# Stepper prezent
|
||||
assert "stepper" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in preview"
|
||||
# Pasul 3 activ
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in preview (pasul 3)"
|
||||
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview"
|
||||
|
||||
|
||||
def test_stepper_pas3_la_preview_direct_mapare_retinuta(client):
|
||||
"""Upload cu mapare retinuta sare direct la preview cu pasul 3 activ.
|
||||
|
||||
Primul upload + mapare memoreaza configuratia.
|
||||
Al doilea upload cu acelasi antet sare direct la preview (pas 3).
|
||||
Pasii 1 si 2 sunt implicit facuti (comportament stepper la pas=3).
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id1 = _upload_and_get_import_id(client)
|
||||
_get_preview_via_mapare(client, import_id1)
|
||||
|
||||
# Al doilea upload — mapare retinuta → preview direct
|
||||
xlsx = _make_xlsx_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test2.xlsx", xlsx,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Preview direct cu mesaj "Mapare retinuta"
|
||||
assert "Mapare retinuta" in text, "Preview direct (mapare retinuta) nu a fost randat"
|
||||
# Stepper prezent cu pasul 3 activ
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in preview direct (mapare retinuta)"
|
||||
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview direct"
|
||||
|
||||
|
||||
def test_stepper_marcheaza_pasii_facuti(client):
|
||||
"""In preview (pas 3), pasii 1 si 2 sunt marcati ca facuti (clasa 'facut').
|
||||
|
||||
Verifica prin prezenta clasei CSS sau a marcajului vizual de 'facut'.
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
text = _get_preview_via_mapare(client, import_id)
|
||||
|
||||
# Clasa "facut" trebuie sa apara pentru pasii 1 si 2 (index < pas curent)
|
||||
assert "facut" in text, \
|
||||
"Clasa/marcajul 'facut' nu a fost gasit in preview (pasii 1 si 2 ar trebui marcati ca facuti)"
|
||||
# Numarul de aparitii: cel putin 2 pasi marcati ca facuti
|
||||
count_facut = text.count("facut")
|
||||
assert count_facut >= 2, \
|
||||
f"Asteptat cel putin 2 pasi marcati ca 'facut' in preview, gasit {count_facut}"
|
||||
|
||||
|
||||
def test_import_hx_target_in_tab(client):
|
||||
"""Fragmentele de import pastreaza hx-target='#import-section'.
|
||||
|
||||
Fragmentul de upload (/_import/reset) trebuie sa contina
|
||||
hx-target='#import-section' pentru ca HTMX sa actualizeze corect
|
||||
containerul din panoul de tab, nu din alta parte.
|
||||
"""
|
||||
r = client.get("/_import/reset")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
assert 'hx-target="#import-section"' in text, \
|
||||
"hx-target='#import-section' nu a fost gasit in fragmentul de upload"
|
||||
# Wrapper-ul extern trebuie sa aiba id="import-section"
|
||||
assert 'id="import-section"' in text, \
|
||||
"id='import-section' nu a fost gasit in fragmentul de upload"
|
||||
|
||||
|
||||
def test_import_forms_pastreaza_csrf(client):
|
||||
"""Formularele de import contin csrf_token (input hidden cu valoare).
|
||||
|
||||
Testeaza atat fragmentul de upload cat si cel de mapare coloane.
|
||||
"""
|
||||
# Fragment upload
|
||||
r_upload = client.get("/_import/reset")
|
||||
assert r_upload.status_code == 200
|
||||
text_upload = r_upload.text
|
||||
# Trebuie sa contina campul csrf_token (poate fi gol in modul dev fara sesiune,
|
||||
# dar campul trebuie sa existe)
|
||||
assert 'name="csrf_token"' in text_upload, \
|
||||
"name='csrf_token' nu a fost gasit in formularul de upload"
|
||||
|
||||
# Fragment mapare coloane
|
||||
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
|
||||
r_map = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", csv_bytes, "text/csv")},
|
||||
)
|
||||
assert r_map.status_code == 200
|
||||
text_map = r_map.text
|
||||
if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare
|
||||
assert 'name="csrf_token"' in text_map, \
|
||||
"name='csrf_token' nu a fost gasit in formularul mapare-coloane"
|
||||
145
tests/test_web_labels.py
Normal file
145
tests/test_web_labels.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Teste pentru app/web/labels.py — modul de etichete umane (US-001, PRD 3.4).
|
||||
|
||||
Ordinea: RED (scrise inainte de implementare), apoi GREEN dupa creare labels.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilitara: extrage starile din CHECK constraint in schema.sql
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _starile_din_schema() -> list[str]:
|
||||
"""
|
||||
Parseaza `app/schema.sql` si returneaza lista starilor din CHECK constraint
|
||||
al coloanei `status` din tabela `submissions`.
|
||||
|
||||
Linia relevanta (schema.sql, tabela submissions):
|
||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error'))
|
||||
|
||||
Testul devine automat RED daca cineva adauga o stare noua in schema
|
||||
fara s-o mapeze in labels.py.
|
||||
"""
|
||||
schema_path = Path(__file__).parent.parent / "app" / "schema.sql"
|
||||
sql = schema_path.read_text(encoding="utf-8")
|
||||
|
||||
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
|
||||
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii.
|
||||
match = re.search(
|
||||
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
|
||||
sql,
|
||||
)
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
|
||||
|
||||
raw = match.group(1)
|
||||
# Extrage valorile dintre ghilimele simple
|
||||
stari = re.findall(r"'([^']+)'", raw)
|
||||
assert stari, "Lista de stari din CHECK este goala — ceva s-a stricat la parsare."
|
||||
return stari
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import modulul de etichete (va esua la RED, inainte de implementare)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_worker_viu():
|
||||
text, subtext, css_class = eticheta_worker(viu=True)
|
||||
assert "Trimitere automata" in text, (
|
||||
f"Textul pentru worker viu trebuie sa contina 'Trimitere automata', got: {text!r}"
|
||||
)
|
||||
assert "activa" in text.lower() or "activa" in subtext.lower(), (
|
||||
f"Starea 'activa' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
|
||||
)
|
||||
# Nu trebuie sa afiseze cuvintele tehnice brute
|
||||
assert "viu" not in text.lower(), f"Textul nu trebuie sa contina 'viu': {text!r}"
|
||||
# Clasa CSS trebuie sa fie definita (non-vida)
|
||||
assert css_class, "css_class nu trebuie sa fie vida pentru worker viu"
|
||||
|
||||
|
||||
def test_eticheta_worker_mort():
|
||||
text, subtext, css_class = eticheta_worker(viu=False)
|
||||
assert "Trimitere automata" in text, (
|
||||
f"Textul pentru worker mort trebuie sa contina 'Trimitere automata', got: {text!r}"
|
||||
)
|
||||
assert "oprita" in text.lower() or "oprita" in subtext.lower(), (
|
||||
f"Starea 'oprita' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
|
||||
)
|
||||
assert "mort" not in text.lower(), f"Textul nu trebuie sa contina 'mort': {text!r}"
|
||||
assert css_class, "css_class nu trebuie sa fie vida pentru worker mort"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste eticheta_rar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_rar_ok():
|
||||
text, subtext, css_class = eticheta_rar(stare="ok")
|
||||
assert "Legatura cu RAR" in text, f"got: {text!r}"
|
||||
assert "functionala" in text.lower() or "functionala" in subtext.lower()
|
||||
assert css_class
|
||||
|
||||
|
||||
def test_eticheta_rar_indisponibil():
|
||||
text, subtext, css_class = eticheta_rar(stare="indisponibil")
|
||||
assert "Legatura cu RAR" in text, f"got: {text!r}"
|
||||
assert "indisponibila" in text.lower() or "indisponibila" in subtext.lower()
|
||||
assert css_class
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test eticheta_stare pentru fiecare stare de submission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_stare_submission():
|
||||
"""Verifica textele umane concrete pentru fiecare stare cunoscuta."""
|
||||
cazuri = {
|
||||
"queued": ("In asteptare", "s-queued"),
|
||||
"sent": ("Declarate la RAR", "s-sent"),
|
||||
"sending": ("Se trimite", "s-sending"),
|
||||
"needs_mapping": ("Lipseste codul", "s-needs_mapping"),
|
||||
"needs_data": ("Date incomplete", "s-needs_data"),
|
||||
"error": ("Eroare", "s-error"),
|
||||
}
|
||||
for status, (fragment_text, clasa) in cazuri.items():
|
||||
text, subtext, css_class = eticheta_stare(status)
|
||||
assert fragment_text.lower() in text.lower(), (
|
||||
f"Status {status!r}: asteptam '{fragment_text}' in text, got {text!r}"
|
||||
)
|
||||
assert css_class == clasa, (
|
||||
f"Status {status!r}: asteptam css_class={clasa!r}, got {css_class!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test parametrizat: TOATE starile din schema au eticheta (anti-drift)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STARI_SCHEMA = _starile_din_schema()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", _STARI_SCHEMA)
|
||||
def test_toate_starile_au_eticheta(status: str):
|
||||
"""
|
||||
Fiecare stare din CHECK constraint (schema.sql) trebuie sa aiba o eticheta
|
||||
non-vida si o clasa CSS non-vida in labels.py.
|
||||
|
||||
Daca cineva adauga o stare noua in schema fara s-o mapeze, acest test devine RED.
|
||||
"""
|
||||
text, subtext, css_class = eticheta_stare(status)
|
||||
assert text, f"Status {status!r}: textul etichetei este vid."
|
||||
assert css_class, f"Status {status!r}: clasa CSS este vida."
|
||||
# Textul nu trebuie sa fie chiar statusul tehnic brut (ex. "queued" afisat ca atare)
|
||||
assert text.lower() != status.lower(), (
|
||||
f"Status {status!r}: eticheta umana este identica cu statusul tehnic — nu e o eticheta umana."
|
||||
)
|
||||
260
tests/test_web_onboarding.py
Normal file
260
tests/test_web_onboarding.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Teste US-005 (PRD 3.4): pagina Acasa cu ghid de pornire (checklist auto-bifat).
|
||||
|
||||
TDD: testele sunt scrise INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET / (tab Acasa) -> ghid cu pasi bifati/nebifati in functie de starea contului
|
||||
- GET /_fragments/acasa -> fragment HTMX pentru tab-ul Acasa
|
||||
- GET /_fragments/submissions -> empty state prietenos cand coada e goala
|
||||
- GET /_fragments/mapari -> empty state prietenos cand nu sunt mapari pendinte
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def _create_account_user(email: str, password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, f"Service Test {email}", active=True)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
return acct_id, user_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit pe /login"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _set_rar_creds(acct_id: int) -> None:
|
||||
"""Seteaza rar_creds_enc pe cont (simuleaza configurarea credentialelor RAR)."""
|
||||
from app.db import get_connection
|
||||
from app.crypto import encrypt_creds
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
enc = encrypt_creds({"email": "test@rar.ro", "password": "parola_rar"})
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(enc, acct_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _add_submission(acct_id: int) -> None:
|
||||
"""Adauga un submission minimal pentru cont (simuleaza un import efectuat)."""
|
||||
import json
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'queued', ?)",
|
||||
(f"test_key_{acct_id}", acct_id, json.dumps({"test": True})),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixture
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "onboarding_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_pas_creds_neconfigurat
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_pas_creds_neconfigurat(client):
|
||||
"""Cont fara creds RAR -> pasul 'Conecteaza contul RAR' e NEbifat."""
|
||||
acct_id, _ = _create_account_user("nocreds@test.com")
|
||||
_login(client, "nocreds@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Pasul de conectare RAR trebuie sa apara
|
||||
assert "Conecteaza" in html or "cont RAR" in html or "RAR" in html, \
|
||||
"Ghidul nu contine referinta la conectarea contului RAR"
|
||||
|
||||
# Cand nu sunt creds, pasul NU trebuie sa fie bifat
|
||||
# Bifarea e semnalata printr-o clasa 'bifat' sau o checkmark langa text-ul RAR
|
||||
# Verificam ca nu apare combinatia "bifat" + "RAR" sau "done" + "RAR" in proximitate
|
||||
# (implementarea exacta e in template, dar pattern-ul de baza: fara `pas-bifat` langa RAR)
|
||||
assert not re.search(
|
||||
r'pas-bifat[^<]*Conecteaza|Conecteaza[^<]*pas-bifat',
|
||||
html, re.DOTALL | re.IGNORECASE
|
||||
), "Pasul RAR nu trebuie sa fie bifat cand contul nu are creds"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_pas_creds_bifat_cand_exista
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_pas_creds_bifat_cand_exista(client):
|
||||
"""Dupa setarea rar_creds_enc pe cont -> pasul 'Conecteaza contul RAR' e bifat."""
|
||||
acct_id, _ = _create_account_user("withcreds@test.com")
|
||||
_set_rar_creds(acct_id)
|
||||
_login(client, "withcreds@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Cand exista creds, pasul trebuie sa fie bifat
|
||||
# Verificam prezenta unui indicator de bifat (clasa 'bifat' sau 'pas-bifat' sau 'done')
|
||||
# Cel putin unul dintre pattern-urile de bifat trebuie sa apara
|
||||
assert re.search(
|
||||
r'pas-bifat|class="[^"]*bifat|done.*RAR|RAR.*done|checkmark.*RAR|RAR.*checkmark',
|
||||
html, re.DOTALL | re.IGNORECASE
|
||||
), "Pasul RAR trebuie sa fie bifat cand contul are creds configurate"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_ascuns_cand_totul_gata
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_ascuns_cand_totul_gata(client):
|
||||
"""Creds setate + cel putin un submission -> ghidul se colapseaza/devine discret."""
|
||||
acct_id, _ = _create_account_user("allset@test.com")
|
||||
_set_rar_creds(acct_id)
|
||||
_add_submission(acct_id)
|
||||
_login(client, "allset@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Cand totul e gata, ghidul compact/discret trebuie sa apara
|
||||
# Fie "Totul e configurat" fie un link discret catre coada
|
||||
assert "Totul e configurat" in html or "totul e configurat" in html.lower(), \
|
||||
"Cand toti pasii sunt gata, trebuie sa apara mesajul discret 'Totul e configurat'"
|
||||
|
||||
# Cardul mare de pasi nu trebuie sa ocupe ecranul
|
||||
# Verificam ca nu mai apare titlul mare al ghidului (Primii pasi)
|
||||
# SAU ca ghidul e marcat ca colapsat (clasa 'ghid-complet' sau similar)
|
||||
# Pattern: fie ghid-complet, fie lipsa titlului complet "Primii pasi" in forma de card mare
|
||||
assert "ghid-complet" in html or "Totul e configurat" in html, \
|
||||
"Ghidul trebuie sa se colapseze cand toti pasii esentiali sunt finalizati"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_linkuri_ghid_duc_la_taburi
|
||||
# ============================================================
|
||||
|
||||
def test_linkuri_ghid_duc_la_taburi(client):
|
||||
"""Link-urile din ghid contin ?tab=cont si ?tab=import."""
|
||||
acct_id, _ = _create_account_user("links@test.com")
|
||||
_login(client, "links@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Ghidul trebuie sa contina link catre tab-ul Cont
|
||||
assert "?tab=cont" in html, \
|
||||
"Ghidul nu contine link catre tab-ul Cont (?tab=cont)"
|
||||
|
||||
# Ghidul trebuie sa contina link catre tab-ul Import
|
||||
assert "?tab=import" in html, \
|
||||
"Ghidul nu contine link catre tab-ul Import (?tab=import)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_empty_state_coada_gol
|
||||
# ============================================================
|
||||
|
||||
def test_empty_state_coada_gol(client):
|
||||
"""Tab Coada fara submissions -> indemn prietenos catre Import, nu mesaj tehnic."""
|
||||
acct_id, _ = _create_account_user("emptyq@test.com")
|
||||
_login(client, "emptyq@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Nu trebuie sa apara mesajul tehnic vechi cu POST /v1/prezentari
|
||||
assert "POST /v1/prezentari" not in html, \
|
||||
"Empty state coada nu trebuie sa contina mesajul tehnic vechi 'POST /v1/prezentari'"
|
||||
|
||||
# Trebuie sa contina un indemn catre Import
|
||||
assert "import" in html.lower() or "Import" in html, \
|
||||
"Empty state coada trebuie sa contina indemn catre Import"
|
||||
|
||||
# Trebuie sa contina link catre ?tab=import
|
||||
assert "?tab=import" in html, \
|
||||
"Empty state coada trebuie sa contina link ?tab=import"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_empty_state_mapari_gol
|
||||
# ============================================================
|
||||
|
||||
def test_empty_state_mapari_gol(client):
|
||||
"""Tab Mapari fara pending -> mesaj prietenos cu indemn (nu lista goala fara context)."""
|
||||
acct_id, _ = _create_account_user("emptym@test.com")
|
||||
_login(client, "emptym@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Trebuie sa apara un mesaj prietenos cand nu sunt mapari pendinte
|
||||
# Nu verificam exact textul, dar trebuie sa existe un indemn/explicatie
|
||||
assert "Nicio operatie nemapata" in html or "totul" in html.lower() or "import" in html.lower(), \
|
||||
"Empty state mapari trebuie sa contina mesaj prietenos"
|
||||
|
||||
# Trebuie sa contina un indemn catre Import sau o explicatie clara
|
||||
# (cel putin link catre import sau mentionarea cuvantului)
|
||||
assert "import" in html.lower() or "?tab=import" in html, \
|
||||
"Empty state mapari trebuie sa contina indemn catre Import"
|
||||
187
tests/test_web_status_fragment.py
Normal file
187
tests/test_web_status_fragment.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Teste US-002 (PRD 3.4): bara de status persistenta cu etichete umane.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET /_fragments/status -> bara de status cu etichete umane, scoped pe cont
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str = "user@test.com", password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test Status", active=True)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
return acct_id, user_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> None:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit pe /login"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _insert_submission(status: str, account_id: int) -> None:
|
||||
"""Insereaza un submission cu status dat pentru un cont dat."""
|
||||
from app.db import get_connection
|
||||
import json
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"test-key-{status}-{account_id}-{os.urandom(4).hex()}",
|
||||
account_id,
|
||||
status,
|
||||
json.dumps({"vin": "TEST", "status": status}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "status_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_fragment_text_uman
|
||||
# ============================================================
|
||||
|
||||
def test_status_fragment_text_uman(client):
|
||||
"""GET /_fragments/status (autentificat) -> contine 'Trimitere automata', NU 'worker viu'."""
|
||||
_create_account_user("status@test.com", "parolasecreta10")
|
||||
_login(client, "status@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa contina textul uman din eticheta_worker (labels.py)
|
||||
assert "Trimitere automata" in html, (
|
||||
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}"
|
||||
)
|
||||
# NU trebuie sa contina textul brut tehnic
|
||||
assert "worker viu" not in html.lower(), (
|
||||
f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}"
|
||||
)
|
||||
# NU trebuie sa contina "mort" (stare tehnica bruta)
|
||||
# (poate aparea in 'oprita' -> acceptam; 'mort' singur -> nu)
|
||||
# Verificam ca nu apare 'mort' ca eticheta standalone
|
||||
assert "viu</div>" not in html, (
|
||||
"Fragmentul contine eticheta bruta 'viu'"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_blocate_defalcare
|
||||
# ============================================================
|
||||
|
||||
def test_status_blocate_defalcare(client):
|
||||
"""Cu submissions blocate in DB, fragmentul arata defalcarea pe motiv (texte umane)."""
|
||||
acct_id, _ = _create_account_user("blocate@test.com", "parolasecreta10")
|
||||
_login(client, "blocate@test.com", "parolasecreta10")
|
||||
|
||||
# Insereaza submissions blocate din fiecare tip
|
||||
_insert_submission("needs_mapping", acct_id)
|
||||
_insert_submission("needs_mapping", acct_id)
|
||||
_insert_submission("needs_data", acct_id)
|
||||
_insert_submission("error", acct_id)
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa arate titlul grupului de blocate
|
||||
assert "Necesita atentia ta" in html, (
|
||||
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
|
||||
)
|
||||
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
|
||||
assert "Lipseste codul prestatiei" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_mapping"
|
||||
)
|
||||
assert "Date incomplete" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_data"
|
||||
)
|
||||
assert "Eroare la trimitere" in html, (
|
||||
"Fragmentul nu arata eticheta pentru error"
|
||||
)
|
||||
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
|
||||
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
|
||||
# (nu strict format, ci prezenta datelor)
|
||||
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_se_reincarca_htmx
|
||||
# ============================================================
|
||||
|
||||
def test_status_se_reincarca_htmx(client):
|
||||
"""Fragmentul contine atribut hx-trigger cu poll periodic (every 15s)."""
|
||||
_create_account_user("htmx@test.com", "parolasecreta10")
|
||||
_login(client, "htmx@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa contina hx-trigger periodic
|
||||
assert "hx-trigger" in html, (
|
||||
f"Fragmentul nu contine atribut hx-trigger. HTML: {html[:500]}"
|
||||
)
|
||||
assert "every 15s" in html, (
|
||||
f"Fragmentul nu contine poll 'every 15s'. HTML: {html[:500]}"
|
||||
)
|
||||
# Trebuie sa aiba endpoint corect pentru auto-refresh
|
||||
assert "/_fragments/status" in html, (
|
||||
"Fragmentul nu contine referinta la /_fragments/status pentru hx-get"
|
||||
)
|
||||
# Trebuie sa aiba un id stabil pe containerul radacina
|
||||
assert 'id="status-bar"' in html, (
|
||||
"Fragmentul nu are id='status-bar' pe containerul radacina"
|
||||
)
|
||||
212
tests/test_web_tabs.py
Normal file
212
tests/test_web_tabs.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Teste US-003 (PRD 3.4): navigare cu tab-uri (shell dashboard).
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET / -> dashboard cu tab-bar si panou activ randat server-side
|
||||
- GET /?tab=<name> -> deep-link, panoul corespunzator randat server-side
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str = "tabs@test.com", password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test Tabs", active=True)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
return acct_id, user_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> None:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit pe /login"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tabs_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_dashboard_are_tabbar
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# Cele 6 tab-uri trebuie sa fie prezente
|
||||
for label in ("Acasa", "Import", "Coada", "Mapari", "Cont", "Nomenclator"):
|
||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tab_implicit_acasa
|
||||
# ============================================================
|
||||
|
||||
def test_tab_implicit_acasa(client):
|
||||
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
|
||||
_create_account_user("implicit@test.com", "parolasecreta10")
|
||||
_login(client, "implicit@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Tab-ul activ trebuie sa aiba aria-selected="true"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
|
||||
# Verificam ca Acasa e cel cu aria-selected=true
|
||||
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
|
||||
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
|
||||
"Tab-ul Acasa nu are aria-selected=true"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_deeplink_tab_import
|
||||
# ============================================================
|
||||
|
||||
def test_deeplink_tab_import(client):
|
||||
"""/?tab=import randeaza panoul Import server-side la full load."""
|
||||
_create_account_user("deeplink@test.com", "parolasecreta10")
|
||||
_login(client, "deeplink@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/?tab=import")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul Import trebuie sa contina id="import-section" (din _upload.html)
|
||||
assert 'id="import-section"' in html, (
|
||||
"Panoul Import nu contine id='import-section' la full load cu ?tab=import"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tab_activ_randat_server_side
|
||||
# ============================================================
|
||||
|
||||
def test_tab_activ_randat_server_side(client):
|
||||
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
|
||||
_create_account_user("serverside@test.com", "parolasecreta10")
|
||||
_login(client, "serverside@test.com", "parolasecreta10")
|
||||
|
||||
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul trebuie sa aiba role="tabpanel"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
|
||||
|
||||
# Import tab server-side
|
||||
resp2 = client.get("/?tab=import")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
|
||||
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_fragmentele_inactive_lazy
|
||||
# ============================================================
|
||||
|
||||
def test_fragmentele_inactive_lazy(client):
|
||||
"""Panourile inactive nu se cer la load — fara hx-trigger=load pe fragmentele inactive."""
|
||||
_create_account_user("lazy@test.com", "parolasecreta10")
|
||||
_login(client, "lazy@test.com", "parolasecreta10")
|
||||
|
||||
# La tab implicit (Acasa): panoul de submissions (Coada) NU trebuie sa fie in HTML
|
||||
# cu hx-trigger="load" (ar insemna ca se incarca automat la deschiderea paginii)
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Verificam ca nu exista un container de submissions cu hx-trigger care include "load"
|
||||
# cand Coada NU e tab-ul activ
|
||||
# Pattern: hx-get="/_fragments/submissions" ... hx-trigger="load..."
|
||||
# Aceasta combinatie NU trebuie sa apara cand tab-ul activ e Acasa
|
||||
submissions_load_pattern = re.search(
|
||||
r'hx-get="/_fragments/submissions"[^>]*hx-trigger="[^"]*load|'
|
||||
r'hx-trigger="[^"]*load[^"]*"[^>]*hx-get="/_fragments/submissions"',
|
||||
html
|
||||
)
|
||||
assert not submissions_load_pattern, (
|
||||
"Fragmentul de submissions (Coada) are hx-trigger=load cand tab-ul activ nu e Coada"
|
||||
)
|
||||
|
||||
# La ?tab=coada: panoul de submissions TREBUIE sa fie in HTML (randat server-side sau cu poll)
|
||||
resp2 = client.get("/?tab=coada")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Cand Coada e activ, containerul de submissions trebuie sa existe
|
||||
assert "/_fragments/submissions" in html2 or "Coada submissions" in html2, (
|
||||
"Panoul Coada nu contine referinta la submissions cand e tab-ul activ"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tabbar_aria
|
||||
# ============================================================
|
||||
|
||||
def test_tabbar_aria(client):
|
||||
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
|
||||
_create_account_user("aria@test.com", "parolasecreta10")
|
||||
_login(client, "aria@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
assert 'role="tab"' in html, "Lipseste role=tab"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
|
||||
Reference in New Issue
Block a user