feat(web): uniformizare/standardizare UI/UX + lifecycle conturi (PRD 5.5)
Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.
- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
`auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
defensiva idempotenta derivata din `active`, gate worker claim_one pe
status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.
Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.
VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).
Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,9 +39,10 @@ def create_account(
|
||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||
cui = _norm_cui(cui)
|
||||
try:
|
||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||
cur = conn.execute(
|
||||
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)",
|
||||
(name, cui, 1 if active else 0),
|
||||
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
|
||||
(name, cui, 1 if active else 0, "active" if active else "pending"),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||
@@ -55,16 +56,72 @@ def create_account(
|
||||
|
||||
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
|
||||
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
||||
Cont inexistent -> ValueError."""
|
||||
Cont inexistent -> ValueError.
|
||||
|
||||
Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active',
|
||||
dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere
|
||||
foloseste `set_status`/`delete_account`.
|
||||
"""
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id))
|
||||
conn.execute(
|
||||
"UPDATE accounts SET active=?, status=? WHERE id=?",
|
||||
(1 if active else 0, "active" if active else "pending", account_id),
|
||||
)
|
||||
|
||||
|
||||
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
|
||||
# retentie); restul sunt reversibile.
|
||||
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
|
||||
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
|
||||
_PROTECTED_ACCOUNT_ID = 1
|
||||
|
||||
|
||||
def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
|
||||
"""Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active`
|
||||
(active=1 doar pentru 'active', altfel 0).
|
||||
|
||||
Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError.
|
||||
Status invalid sau cont inexistent -> ValueError.
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"status invalid: {status}")
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
if account_id == _PROTECTED_ACCOUNT_ID and status != "active":
|
||||
raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).")
|
||||
conn.execute(
|
||||
"UPDATE accounts SET active=?, status=? WHERE id=?",
|
||||
(1 if status == "active" else 0, status, account_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
||||
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
||||
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
||||
revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic
|
||||
`ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat.
|
||||
|
||||
Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram
|
||||
tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches,
|
||||
NU acest tombstone — de aceea purjam PII aici, la momentul stergerii."""
|
||||
set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
|
||||
(account_id,),
|
||||
)
|
||||
|
||||
|
||||
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id."""
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||
(stergere soft -> invizibile in panou)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id"
|
||||
"SELECT id, name, cui, active, status, created_at FROM accounts "
|
||||
"WHERE status != 'deleted' ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
12
app/db.py
12
app/db.py
@@ -63,6 +63,18 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if "active" not in acc_cols:
|
||||
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||
acc_cols.add("active")
|
||||
if "status" not in acc_cols:
|
||||
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
|
||||
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
|
||||
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
|
||||
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
|
||||
)
|
||||
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||
|
||||
@@ -11,6 +11,13 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
name TEXT NOT NULL,
|
||||
cui TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
|
||||
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
|
||||
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
|
||||
-- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste,
|
||||
-- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere,
|
||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import list_accounts, set_active
|
||||
from ..accounts import list_accounts, set_active, set_status, delete_account
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
@@ -49,16 +49,20 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
||||
emails = _emails_by_account(conn)
|
||||
for acct in accounts:
|
||||
acct["email"] = emails.get(acct["id"])
|
||||
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
|
||||
active = [a for a in accounts if a["active"] and a["id"] != 1]
|
||||
default = next((a for a in accounts if a["id"] == 1), None)
|
||||
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
|
||||
suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1]
|
||||
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
pending=pending,
|
||||
active=active,
|
||||
default_account=default,
|
||||
suspended=suspended,
|
||||
error=error,
|
||||
is_authenticated=True,
|
||||
is_admin=True,
|
||||
), status_code=status_code)
|
||||
|
||||
|
||||
@@ -74,28 +78,66 @@ async def admin_get(request: Request):
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
||||
async def admin_activate(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
|
||||
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
||||
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
|
||||
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
|
||||
`action`: activate | block | archive | delete."""
|
||||
for aid in ids:
|
||||
try:
|
||||
if action == "activate":
|
||||
set_status(conn, aid, "active")
|
||||
elif action == "block":
|
||||
set_status(conn, aid, "blocked")
|
||||
elif action == "archive":
|
||||
set_status(conn, aid, "archived")
|
||||
elif action == "delete":
|
||||
delete_account(conn, aid)
|
||||
except ValueError:
|
||||
continue # cont de sistem / inexistent -> sarit
|
||||
|
||||
|
||||
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
|
||||
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
|
||||
Evita 4 handlere copy-paste care difera doar prin verb."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
set_active(conn, account_id, True)
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
_apply_lifecycle(conn, account_id, action)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
||||
async def admin_activate(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Activeaza unul sau mai multe conturi (bulk). PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "activate")
|
||||
|
||||
|
||||
@router.post("/admin/block", response_class=HTMLResponse)
|
||||
async def admin_block(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "block")
|
||||
|
||||
|
||||
@router.post("/admin/archive", response_class=HTMLResponse)
|
||||
async def admin_archive(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "archive")
|
||||
|
||||
|
||||
@router.post("/admin/delete", response_class=HTMLResponse)
|
||||
async def admin_delete(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
||||
|
||||
|
||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||
async def admin_deactivate(
|
||||
request: Request,
|
||||
|
||||
@@ -331,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"badges": badges,
|
||||
"is_authenticated": True,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
|
||||
@@ -44,15 +44,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Subordonat: ajutor rapid pe un rand discret ===
|
||||
US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos
|
||||
pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #}
|
||||
<div style="margin-top:10px; font-size:13px; color:var(--muted);
|
||||
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
|
||||
<span>Ajutor:</span>
|
||||
<a href="/?tab=mapari">Mapari</a>
|
||||
<a href="/?tab=nomenclator">Coduri RAR</a>
|
||||
</div>
|
||||
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
|
||||
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
|
||||
|
||||
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
|
||||
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
||||
|
||||
{# US-007: comutator pe COADA in loc de bifa "auto-send".
|
||||
Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele
|
||||
poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu
|
||||
semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca
|
||||
bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get())
|
||||
la /_import/.../mapare-operatie). Zero atingere backend.
|
||||
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
|
||||
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
|
||||
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
|
||||
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
|
||||
|
||||
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
|
||||
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
|
||||
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
|
||||
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
|
||||
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
|
||||
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
|
||||
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
|
||||
- checked: starea initiala (H4 - reflecta valoarea STOCATA per mapare). #}
|
||||
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
|
||||
{% macro autosend_toggle(form_id='', checked=True) -%}
|
||||
<div class="autosend-toggle" style="display:flex; flex-direction:column; gap:4px;">
|
||||
<span class="muted" style="font-size:12px;">La fisierele viitoare cu aceasta operatie:</span>
|
||||
<label class="chk" style="display:inline-flex; align-items:center; gap:8px; min-height:44px;">
|
||||
<input type="checkbox" name="auto_send" value="true"
|
||||
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
||||
{%- if checked %} checked{% endif %}
|
||||
aria-label="Pune automat in coada la fisierele viitoare cu aceasta operatie">
|
||||
<span><strong>Pune automat in coada</strong></span>
|
||||
</label>
|
||||
<span class="muted" style="font-size:11px;">
|
||||
Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie;
|
||||
nimic nu pleaca la RAR pana confirmi.
|
||||
</span>
|
||||
</div>
|
||||
<label class="autosend-toggle"
|
||||
title="Auto = pune automat in coada la fisierele viitoare cu aceasta operatie. Manual = tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
||||
style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; min-height:36px; cursor:pointer; font-size:13px;">
|
||||
<span class="muted">Manual</span>
|
||||
<input type="checkbox" name="auto_send" value="true"
|
||||
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
||||
{%- if checked %} checked{% endif %}
|
||||
aria-label="In coada: Auto (bifat) sau Manual (nebifat), pentru aceasta operatie"
|
||||
style="width:32px; height:18px; cursor:pointer; accent-color:var(--accent);">
|
||||
<span><strong>Auto</strong></span>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -9,7 +9,22 @@
|
||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
|
||||
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
|
||||
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
|
||||
o singura data, ascunsa implicit. #}
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
||||
<details class="ajutor-mapari" style="margin:0 0 12px;">
|
||||
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
|
||||
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
|
||||
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
|
||||
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
|
||||
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
|
||||
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
|
||||
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if not pending %}
|
||||
<div class="empty">
|
||||
@@ -17,18 +32,13 @@
|
||||
<a href="/?tab=acasa">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>.
|
||||
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Operatie</th>
|
||||
<th>Sugestii</th>
|
||||
<th>Cod RAR</th>
|
||||
<th>Punere in coada</th>
|
||||
<th>In coada</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@@ -88,17 +98,14 @@
|
||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau punerea in coada si salveaza;
|
||||
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</p>
|
||||
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Operatie</th>
|
||||
<th>Cod RAR</th>
|
||||
<th>Punere in coada</th>
|
||||
<th>In coada</th>
|
||||
<th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@@ -160,7 +167,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
|
||||
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
|
||||
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
|
||||
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
|
||||
{% if rows %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Cod</th><th>Denumire</th><th>Actualizat</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th>Cod</th>
|
||||
<th>Denumire</th>
|
||||
<th>Actualizat</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><span class="pill">{{ r.cod_prestatie }}</span></td>
|
||||
<td>{{ r.nume_prestatie }}</td>
|
||||
<td style="white-space:normal;">{{ r.nume_prestatie }}</td>
|
||||
<td class="muted">{{ r.updated_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,6 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
||||
{% set VERBS = {
|
||||
'activate': ('Activeaza', '/admin/activate', ''),
|
||||
'block': ('Blocheaza', '/admin/block', ''),
|
||||
'archive': ('Arhiveaza', '/admin/archive', ''),
|
||||
'delete': ('Sterge', '/admin/delete', 'danger')
|
||||
} %}
|
||||
|
||||
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
||||
{% if rows %}
|
||||
{# Bara bulk: form propriu (id=bulk-<block>); checkbox-urile randurilor se leaga prin atributul
|
||||
HTML5 form= (fara form-uri imbricate). Ascunsa pana exista o selectie (JS). #}
|
||||
<form id="bulk-{{ block_id }}" method="post" class="bulk-form" data-block="{{ block_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="bulk-bar" hidden>
|
||||
<span class="bulk-count muted" style="font-size:13px;">0 selectate</span>
|
||||
{% for v in bulk_verbs %}
|
||||
{% set label, action, cls = VERBS[v] %}
|
||||
<button type="submit" formaction="{{ action }}"
|
||||
{% if v == 'delete' %}onclick="return confirm('Stergi conturile selectate? (stergere soft, datele se purjeaza)');"{% endif %}
|
||||
style="{% if cls == 'danger' %}background:var(--card); color:var(--err); border-color:var(--err);{% endif %}">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
||||
aria-label="Selecteaza tot"></th>
|
||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for acct in rows %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="account_id" value="{{ acct.id }}" form="bulk-{{ block_id }}"
|
||||
class="row-check" data-block="{{ block_id }}"
|
||||
aria-label="Selecteaza contul {{ acct.name }}"></td>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td><span class="pill">{{ acct.status }}</span></td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
<details class="kebab">
|
||||
<summary class="cardlink" style="list-style:none; cursor:pointer; display:inline-flex;
|
||||
padding:4px 10px;" aria-label="Actiuni pentru {{ acct.name }}">⋯</summary>
|
||||
<div class="kebab-menu">
|
||||
{% for v in row_verbs %}
|
||||
{% set label, action, cls = VERBS[v] %}
|
||||
{# Confirm fara nume interpolat: un apostrof in numele firmei (free-form) ar rupe
|
||||
string-ul JS din atributul inline (entitatea ' e decodata inainte de parse). #}
|
||||
<form method="post" action="{{ action }}"
|
||||
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit" {% if cls == 'danger' %}style="color:var(--err);"{% endif %}>{{ label }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<style>
|
||||
/* Bara de actiuni bulk — ascunsa pana exista selectie. `[hidden]` trebuie sa invinga
|
||||
display-ul, deci stilul sta in CSS (NU inline cu display:flex, care ar invinge [hidden]). */
|
||||
.bulk-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px;
|
||||
padding:8px 10px; border:1px solid var(--line); border-radius:8px;
|
||||
background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
|
||||
.bulk-bar[hidden] { display:none; }
|
||||
/* Kebab per-rand (reuseaza estetica meniului de cont) */
|
||||
.kebab { position:relative; display:inline-block; }
|
||||
.kebab > summary::-webkit-details-marker { display:none; }
|
||||
.kebab-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:140px; z-index:40;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
|
||||
.kebab[open] > summary { background:var(--line); }
|
||||
.kebab-menu form { margin:0; }
|
||||
.kebab-menu button { display:block; width:100%; text-align:left; background:transparent; border:none;
|
||||
color:var(--ink); font:inherit; padding:7px 10px; border-radius:6px; cursor:pointer;
|
||||
min-height:36px; }
|
||||
.kebab-menu button:hover { background:var(--line); }
|
||||
</style>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
|
||||
<h2 style="margin:0;">Panou admin</h2>
|
||||
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
|
||||
@@ -10,96 +108,45 @@
|
||||
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Conturi in asteptare -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
|
||||
{% if pending %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in pending %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/activate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit">Activeaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont in asteptare.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ lifecycle_block("Conturi in asteptare", pending, "pending",
|
||||
['activate', 'block', 'archive', 'delete'],
|
||||
['activate', 'block', 'archive', 'delete']) }}
|
||||
|
||||
<!-- Conturi active -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
|
||||
{% if active %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in active %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/deactivate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ lifecycle_block("Conturi active", active, "active",
|
||||
['block', 'archive', 'delete'],
|
||||
['block', 'archive', 'delete']) }}
|
||||
|
||||
<!-- Contul dev default (id=1) -->
|
||||
{% if default_account %}
|
||||
<div class="card" style="border-color:var(--muted);">
|
||||
<p class="muted" style="margin:0;font-size:13px;">
|
||||
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
|
||||
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
|
||||
{{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
|
||||
['activate', 'delete'],
|
||||
['activate', 'delete']) }}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
|
||||
document.querySelectorAll('.master-check').forEach(function(master) {
|
||||
var block = master.getAttribute('data-block');
|
||||
var rows = Array.prototype.slice.call(
|
||||
document.querySelectorAll('.row-check[data-block="' + block + '"]'));
|
||||
var form = document.getElementById('bulk-' + block);
|
||||
var bar = form ? form.querySelector('.bulk-bar') : null;
|
||||
var count = form ? form.querySelector('.bulk-count') : null;
|
||||
|
||||
function refresh() {
|
||||
var n = rows.filter(function(r) { return r.checked; }).length;
|
||||
if (bar) bar.hidden = (n === 0);
|
||||
if (count) count.textContent = n + ' selectate';
|
||||
master.checked = (n > 0 && n === rows.length);
|
||||
master.indeterminate = (n > 0 && n < rows.length);
|
||||
}
|
||||
master.addEventListener('change', function() {
|
||||
rows.forEach(function(r) { r.checked = master.checked; });
|
||||
refresh();
|
||||
});
|
||||
rows.forEach(function(r) { r.addEventListener('change', refresh); });
|
||||
refresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -116,6 +116,22 @@
|
||||
.eroare-3n-label { font-weight:500; }
|
||||
/* Inline fix per camp in preview */
|
||||
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
||||
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
|
||||
.cont-menu-wrap { position:relative; }
|
||||
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
|
||||
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
|
||||
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.icon-btn:hover { background:var(--line); }
|
||||
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
|
||||
.cont-menu[hidden] { display:none; }
|
||||
.cont-menu a, .cont-menu button { display:block; width:100%; text-align:left; background:transparent;
|
||||
border:none; color:var(--ink); text-decoration:none; font:inherit; padding:8px 10px;
|
||||
border-radius:6px; cursor:pointer; min-height:36px; }
|
||||
.cont-menu a:hover, .cont-menu button:hover { background:var(--line); }
|
||||
.cont-menu hr { border:none; border-top:1px solid var(--line); margin:4px 0; }
|
||||
.cont-menu form { margin:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -123,11 +139,30 @@
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
|
||||
<button id="tema-toggle"
|
||||
<button id="tema-toggle" class="icon-btn"
|
||||
aria-label="Comuta tema (luminos/intunecat)"
|
||||
title="Comuta tema"
|
||||
style="background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer; border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px; line-height:1; display:inline-flex; align-items:center; justify-content:center;">☀</button>
|
||||
title="Comuta tema">☀</button>
|
||||
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
||||
{% if is_authenticated|default(false) %}
|
||||
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
|
||||
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
||||
<div class="cont-menu-wrap">
|
||||
<button id="cont-menu-toggle" class="icon-btn"
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Panou admin</a>{% endif %}
|
||||
<hr>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
|
||||
<button role="menuitem" type="submit">Iesi din cont</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
@@ -165,5 +200,37 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
|
||||
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
|
||||
(function() {
|
||||
var toggle = document.getElementById('cont-menu-toggle');
|
||||
var menu = document.getElementById('cont-menu');
|
||||
if (!toggle || !menu) return;
|
||||
function open() {
|
||||
menu.hidden = false;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
}
|
||||
function close(refocus) {
|
||||
menu.hidden = true;
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('click', onDocClick, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
if (refocus) toggle.focus();
|
||||
}
|
||||
function onDocClick(e) {
|
||||
if (!menu.contains(e.target) && e.target !== toggle) close(false);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(true); }
|
||||
}
|
||||
toggle.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open(); else close(false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Nav cont: link admin (doar pentru admini) + logout -->
|
||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
|
||||
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
|
||||
<form method="post" action="/logout" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
|
||||
</form>
|
||||
</div>
|
||||
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
|
||||
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
|
||||
|
||||
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
@@ -20,14 +14,12 @@
|
||||
|
||||
<!-- Tab-bar: navigare intre sectiuni -->
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc
|
||||
ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #}
|
||||
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
|
||||
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
|
||||
{% set tabs = [
|
||||
("acasa", "Acasa", "tab-acasa"),
|
||||
("mapari", "Mapari", "tab-mapari"),
|
||||
("cont", "Cont", "tab-cont"),
|
||||
("nomenclator", "Nomenclator", "tab-nomenclator"),
|
||||
("integrare", "Integrare", "tab-integrare")
|
||||
("mapari", "Mapari", "tab-mapari")
|
||||
] %}
|
||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
|
||||
|
||||
@@ -145,7 +145,9 @@ def claim_one(conn) -> dict | None:
|
||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||
"WHERE s.status='queued' "
|
||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||
"AND COALESCE(a.active, 1) = 1 "
|
||||
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
|
||||
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
||||
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
|
||||
"ORDER BY s.id LIMIT 1",
|
||||
(_iso(_now()),),
|
||||
).fetchone()
|
||||
|
||||
File diff suppressed because one or more lines are too long
277
docs/design/5.5-uniformizare-ui.md
Normal file
277
docs/design/5.5-uniformizare-ui.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Design 5.5 — Uniformizare & standardizare UI/UX
|
||||
|
||||
**Stare**: aprobat (decizii utilizator 2026-06-23, vezi §10)
|
||||
**Context**: dashboard web HTMX (`app/web/templates/`), paleta dark/light deja livrata (5.3),
|
||||
erori 3-niveluri (5.4). Acest document = sursa de adevar **vizuala** pentru PRD 5.5. Unde PRD-ul
|
||||
descrie *ce* livram pe stories, aici descriem *cum arata* si *de ce*.
|
||||
|
||||
> Nu reinventam estetica. Paleta, tipografia si tokenii din `base.html` (5.3) raman **NESCHIMBATI
|
||||
> la octet**. Standardizarea = aducem toate tabelele si paginile la acelasi vocabular de componente
|
||||
> care exista deja in tabelul Trimiteri (`_submissions.html`), tabelul considerat corect de referinta.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema (audit pe codul real)
|
||||
|
||||
Inventar al neuniformitatii curente:
|
||||
|
||||
| Suprafata | Simptom | Referinta corecta |
|
||||
|-----------|---------|-------------------|
|
||||
| Tabel **Mapari** (`_mapari.html`) | Labartat: coloana "Punere in coada" injecteaza prin macro `autosend_toggle` 3 randuri de text explicativ pe **fiecare** linie → randuri inalte, butoanele **Salveaza/Sterge** ies din viewport, trebuie scroll orizontal | grila Trimiteri |
|
||||
| Tabel **Nomenclator** (`_nomenclator.html`) | Functional dar minim; nu imparte exact acelasi aspect/hover/aliniere cu Trimiteri | grila Trimiteri |
|
||||
| **Acasa** (`_acasa.html`) | Sectiune "Ajutor: Mapari / Coduri RAR" redundanta (wayfinding repetat) | — (se elimina) |
|
||||
| **Navigare** | Cont, Integrare, Nomenclator stau ca tab-uri amestecate cu lucrul zilnic; logout + link admin sunt agatate ad-hoc in coltul dreapta-sus al dashboard-ului, absente pe alte pagini | meniu de cont dedicat |
|
||||
| **Panou admin** (`admin.html`) | Conturile in asteptare au doar "Activeaza" per-rand; lipsesc selectie multipla si actiunile blocare/arhivare/stergere. Nota "Cont dev implicit" e jargon intern nederivabil | tabel cu selectie + bara bulk |
|
||||
|
||||
Principiu de standardizare: **un singur tabel, o singura componenta de antet de sectiune, un singur
|
||||
loc pentru ajutor** (link/disclosure, nu text inline repetat pe randuri).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design tokens (existenti — se reutilizeaza, nu se modifica)
|
||||
|
||||
Din `base.html` (`:root` dark + `[data-theme="light"]`). Citat aici doar ca referinta; **nicio
|
||||
valoare noua de culoare**. Orice suprafata noua foloseste `color-mix(... var(--card))` pentru stari
|
||||
(lectia 5.3: zero literali hardcodati, altfel se sparge light mode).
|
||||
|
||||
```
|
||||
--bg --card --ink --muted --line
|
||||
--ok (verde) --warn (chihlimbar) --err (rosu) --accent (albastru)
|
||||
```
|
||||
|
||||
Spacing: cardurile 16-20px padding; celule tabel `8px 10px`; gap-uri 6/8/12/16px (scara existenta).
|
||||
Radius: 6px controale, 10px carduri, 99px pill-uri. Tipografie: tabel 14px `tabular-nums`,
|
||||
antet `th` 12px uppercase `--muted`. **Nu introducem fonturi sau marimi noi.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Componenta canonica: Tabelul standard
|
||||
|
||||
Tabelul Trimiteri defineste contractul. Orice tabel din aplicatie il respecta:
|
||||
|
||||
```
|
||||
.tablewrap > table
|
||||
thead th -> 12px uppercase, color --muted, font-weight 500, white-space nowrap
|
||||
tbody td -> 14px, padding 8px 10px, border-bottom 1px var(--line), nowrap implicit
|
||||
stare -> <span class="pill {s-*}">{text uman}</span> (glifa+text, nu doar culoare)
|
||||
coloana lunga (motiv) -> white-space:normal; max-width:280px (singura exceptie de la nowrap)
|
||||
empty state -> .empty (centrat, --muted, cu CTA contextual)
|
||||
```
|
||||
|
||||
Reguli care fac diferenta vizibila fata de "labartat":
|
||||
1. **Coloanele de control sunt inguste si nowrap.** Niciun text explicativ in celule. Explicatiile
|
||||
traiesc o singura data, in antetul cardului (link "Ajutor") sau intr-un `<details>`.
|
||||
2. **Actiunile incap fara scroll orizontal.** Coloana "Actiuni" la dreapta, `white-space:nowrap`,
|
||||
butoane scurte. Pe ecrane inguste scroll-ul ramane IN card (`.tablewrap`), nu in pagina.
|
||||
3. **Densitate constanta.** Inaltimea randului = o linie de text + padding. Sub-text (ex. "2 blocate",
|
||||
"acum: COD") merge in `<div class="muted" style="font-size:12px">` sub valoarea principala, nu
|
||||
pe coloana separata.
|
||||
|
||||
### 3.1 Antet de sectiune standard (cu Ajutor)
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| De rezolvat [ Ajutor ] | <- h2 15px la stanga, link la dreapta
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
`Ajutor` = link discret `.cardlink` care comuta un `<details>`/panou de text (vezi §5). Mutam acolo
|
||||
toata proza care azi se repeta pe randuri. Un singur loc, citit la nevoie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tabelul Mapari — inainte / dupa
|
||||
|
||||
### Inainte (labartat)
|
||||
Fiecare rand din "De rezolvat" si "Mapari salvate" poarta `autosend_toggle`, care randeaza:
|
||||
- "La fisierele viitoare cu aceasta operatie:" (12px)
|
||||
- checkbox + **"Pune automat in coada"**
|
||||
- "Nebifat = «Tine pentru verificare». Doar pentru aceasta operatie; nimic nu pleaca la RAR..." (11px)
|
||||
|
||||
x N randuri. Coloana e mai lata decat selectul de cod; Salveaza/Sterge sunt impinse afara.
|
||||
|
||||
### Dupa (compact, ca Trimiteri)
|
||||
|
||||
```
|
||||
De rezolvat [ Ajutor ]
|
||||
-----------------------------------------------------------------
|
||||
OPERATIE SUGESTII COD RAR IN COADA ACTIUNI
|
||||
RevTehBP A012 (88%) [ A012 v ] (o) Auto [Salveaza]
|
||||
2 blocate ( ) Manual
|
||||
-----------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana **IN COADA** = comutator scurt cu doua stari etichetate **Auto** / **Manual** (radio sau
|
||||
switch), fara nicio propozitie. Tooltip pe control: "Auto = pune automat in coada la fisierele
|
||||
viitoare cu aceasta operatie; Manual = tine pentru verificare."
|
||||
- Explicatia completa (de ce exista maparile, ce inseamna Auto vs Manual, ce e blocat) → in panoul
|
||||
**Ajutor** din antet, scris o singura data.
|
||||
- **Invariant backend pastrat**: controlul emite tot `name="auto_send" value="true"` cu semantica de
|
||||
prezenta (bifat→true, absent→false), exact ca azi. Zero atingere backend (lectia 5.3/3.6:
|
||||
reskin la nivel de macro, parserele `/mapari` si `/_import/.../mapare-operatie` raman valide).
|
||||
- "Mapari salvate" si "Formate de coloane" → aceeasi grila; sub-textul ("acum: COD — nume",
|
||||
"N coloane", maparea coloana→camp) ramane `muted` 12px sub valoare, nu pe coloane separate verbose.
|
||||
|
||||
Macro-ul `autosend_toggle` se rescrie compact (acelasi `name`/`form`/`checked`), deci se schimba
|
||||
intr-un singur loc si se propaga si in fluxul de import (mapcoloane) unde e refolosit.
|
||||
|
||||
---
|
||||
|
||||
## 5. Panoul Ajutor (mapari)
|
||||
|
||||
Un `<details>` nativ in antetul cardului "De rezolvat" (sau link care expandeaza acelasi `<details>`),
|
||||
inchis implicit, fara JS:
|
||||
|
||||
```
|
||||
Ajutor (v)
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
- Operatii necunoscute raman blocate in needs_mapping si NU pleaca la RAR pana le mapezi.
|
||||
- Sugestiile (%) vin din potrivire fuzzy pe denumire — verifica-le inainte sa salvezi.
|
||||
- In coada: Auto = la urmatoarele fisiere cu aceasta operatie, randurile intra automat in coada.
|
||||
Manual = raman pentru verificare; nimic nu pleaca la RAR pana confirmi tu.
|
||||
- La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
```
|
||||
|
||||
Avantaj: text scris o data, accesibil, fara cost de inaltime pe fiecare rand. `<details>` =
|
||||
accesibil din tastatura nativ, fara dependente.
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigare: meniu hamburger (decizie: dropdown ancorat dreapta-sus)
|
||||
|
||||
### 6.1 Header
|
||||
|
||||
```
|
||||
[Gateway RAR AUTOPASS] [test] [☀] v1.0 [☰]
|
||||
|
|
||||
+------------------+
|
||||
| Cont |
|
||||
| Integrare |
|
||||
| Nomenclator |
|
||||
| Panou admin | <- doar admin
|
||||
|------------------|
|
||||
| Iesi din cont | <- form POST /logout
|
||||
+------------------+
|
||||
```
|
||||
|
||||
- Iconita `☰` (`min 36x36`, `aria-label="Meniu cont"`, `aria-expanded`, `aria-controls`) langa toggle-ul
|
||||
de tema. Dropdown ancorat sub iconita, aliniat la dreapta. **Fara overlay** pe pagina.
|
||||
- Inchidere: click in afara, `Esc`, sau selectarea unui element. Focus trap minimal: `Esc` readuce
|
||||
focusul pe `☰`. Navigare cu sageti optionala (consistent cu pattern-ul tab existent), dar `Tab`
|
||||
natural e suficient.
|
||||
- Continut **dependent de autentificare** (vezi §6.3).
|
||||
|
||||
### 6.2 Tab-bar dupa mutare (decizie: doar Acasa · Mapari)
|
||||
|
||||
```
|
||||
[ Acasa ] [ Mapari ]
|
||||
```
|
||||
|
||||
Cont, Integrare, Nomenclator parasesc tab-bar-ul → meniul `☰`. Raman doar cele doua suprafete de
|
||||
**lucru zilnic**. Badge-urile de contoare (Mapari) raman pe tab. Deep-link `?tab=` si rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` raman valide (accesate acum din meniu, nu din tab-bar) —
|
||||
deci zero rute moarte, doar punctul de intrare se muta.
|
||||
|
||||
### 6.3 Stare de autentificare in header
|
||||
|
||||
`base.html` e partajat de `login.html`, `signup.html`, `dashboard.html`, `admin.html`. Meniul trebuie
|
||||
sa stie daca esti logat:
|
||||
|
||||
- **Autentificat**: arata Cont, Integrare, Nomenclator, (Panou admin daca `is_admin`), separator,
|
||||
"Iesi din cont" (form `POST /logout` cu `csrf_token`).
|
||||
- **Neautentificat** (login/signup): meniul arata doar "Autentificare" / "Inregistrare" (sau iconita
|
||||
`☰` ascunsa pe aceste pagini — vezi PRD US). Niciun link de cont, niciun logout.
|
||||
|
||||
Necesita ca `base.html` sa primeasca `is_authenticated`, `is_admin`, `csrf_token` in context. Se
|
||||
adauga ca un helper de context partajat (un singur loc), nu duplicat in fiecare render. Acesta e
|
||||
singurul "backend touch" din zona de navigare si trebuie sa fie aditiv si defensiv (lipsa cheilor →
|
||||
meniu in stare neautentificata, nu eroare).
|
||||
|
||||
---
|
||||
|
||||
## 7. Panou admin: selectie + actiuni bulk
|
||||
|
||||
### 7.1 Tabel conturi in asteptare (si analog conturi active)
|
||||
|
||||
```
|
||||
[v] Selecteaza tot 2 selectate:
|
||||
[Activeaza] [Blocheaza] [Arhiveaza] [Sterge] <- bara bulk, apare la selectie
|
||||
-----------------------------------------------------------------------
|
||||
[v] ID COMPANIE CUI EMAIL INREGISTRAT ACTIUNI
|
||||
[v] 7 Auto SRL RO123 a@b.ro 12.06.2026 [ ... ]
|
||||
[ ] 8 Moto SA RO456 c@d.ro 13.06.2026 [ ... ]
|
||||
-----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana de **checkbox** la stanga + un master "selecteaza tot" in antet.
|
||||
- **Bara de actiuni bulk** ascunsa pana exista o selectie; afiseaza numarul selectat si butoanele
|
||||
contextuale. Actioneaza pe toate randurile bifate (POST cu lista de `account_id`).
|
||||
- **Actiuni per-rand** in meniul `[ ... ]` (kebab): aceleasi verbe, pentru o singura tinta.
|
||||
- Verbele, ca stari de cont distincte (vezi §7.2): **Activeaza, Blocheaza, Arhiveaza, Sterge**.
|
||||
- `Sterge` = actiune distructiva → `hx-confirm` / dialog de confirmare obligatoriu.
|
||||
- `Blocheaza`/`Arhiveaza` reversibile → confirmare doar pe bulk (cantitate).
|
||||
- Stari vizuale ale verbelor: distructiv (`Sterge`) cu `color:var(--err)`; restul neutre `.cardlink`.
|
||||
|
||||
### 7.2 Model de stare cont (impact backend — vezi PRD riscuri)
|
||||
|
||||
Azi: `accounts.active` (0/1); "pending" = inregistrat dar `active=0`. Cele 4 verbe cer stari
|
||||
distincte care nu incap intr-un bool. Propunere (PRD o ratifica): coloana `accounts.status`
|
||||
TEXT, migrare defensiva, derivata din `active` la prima rulare:
|
||||
|
||||
```
|
||||
pending -> inregistrat, neactivat inca (active=0, status nesetat istoric)
|
||||
active -> operational (active=1)
|
||||
blocked -> suspendat reversibil (nu logheaza, worker nu trimite)
|
||||
archived -> ascuns din liste, date pastrate (read-only)
|
||||
deleted -> stergere (GDPR/L.142) (hard delete SAU soft cu purge)
|
||||
```
|
||||
|
||||
- Worker `claim_one` gate-uieste pe **status='active'** (azi pe `COALESCE(active,1)=1`) — schimbare
|
||||
semantica de pastrat compatibila: `active=1 ⇔ status='active'`.
|
||||
- **Contul dev id=1 e protejat** de Blocheaza/Arhiveaza/Sterge (cont de sistem), exact ca azi la
|
||||
activate/deactivate. Daca e selectat in bulk, e sarit, nu eroare.
|
||||
- Nota "Cont dev implicit (id=1)" din pagina **se elimina** (jargon intern, nederivabil de operator).
|
||||
Protectia ramane in cod, nu o explicam in UI.
|
||||
|
||||
> Aceasta e singura zona cu schema/backend real. Restul livrabilei e UI pur (reskin + reasezare).
|
||||
|
||||
---
|
||||
|
||||
## 8. Accesibilitate & paritate tema
|
||||
|
||||
- **AA pe light+dark** pentru orice text nou (lectia 5.3: verzi/rosii hardcodate cad sub AA). Stari
|
||||
doar prin `color-mix(... var(--card))`, niciun literal.
|
||||
- Stare = **glifa + text**, nu doar culoare (pill-urile existente respecta deja).
|
||||
- Meniul `☰`: `aria-expanded`, `aria-controls`, inchidere pe `Esc`, focus readus pe trigger.
|
||||
- `<details>` Ajutor: accesibil nativ din tastatura.
|
||||
- Checkbox-uri admin: `aria-label` per rand ("Selecteaza contul {companie}"); master = "Selecteaza tot".
|
||||
- Toate controalele >=36px zona de atins (consistent cu toggle tema / `.cardlink`).
|
||||
|
||||
## 9. Motiune
|
||||
|
||||
Minima, consistenta cu existentul: dropdown `☰` fade/translate scurt (~120ms, ca `.tab-link`
|
||||
transition). `<details>` = comportament nativ. Fara animatii noi de amploare. Respecta
|
||||
`prefers-reduced-motion` daca adaugam tranzitii (omitere la cerere).
|
||||
|
||||
## 10. Decizii utilizator (2026-06-23)
|
||||
|
||||
1. Meniu hamburger = **dropdown ancorat dreapta-sus** (nu drawer).
|
||||
2. Tab-bar = **Acasa · Mapari**; Nomenclator + Cont + Integrare + Panou admin → meniul `☰`.
|
||||
3. Mapari = **grila compacta ca Trimiteri**, toggle scurt **Auto/Manual**, link **Ajutor** in antet;
|
||||
textul repetat de pe randuri se elimina.
|
||||
4. Admin = **selectie cu bife + bara de actiuni bulk** (Activeaza/Blocheaza/Arhiveaza/Sterge) +
|
||||
actiuni per-rand; nota "cont dev implicit" **eliminata**.
|
||||
5. Sectiunea "Ajutor" de pe Acasa se **elimina**.
|
||||
6. Nomenclator capata exact aspectul tabelului Trimiteri.
|
||||
|
||||
## 11. Componente atinse (harta pentru PRD)
|
||||
|
||||
| Componenta | Fisier | Tip schimbare |
|
||||
|------------|--------|---------------|
|
||||
| Header + meniu `☰` + context auth | `base.html` (+ context render rute) | reasezare + mic backend context |
|
||||
| Tab-bar redus | `dashboard.html` | reasezare |
|
||||
| Acasa fara Ajutor | `_acasa.html` | stergere |
|
||||
| Mapari standard + toggle compact + Ajutor | `_mapari.html`, `_macros.html` | reskin UI (zero backend) |
|
||||
| Nomenclator ca Trimiteri | `_nomenclator.html` | reskin UI |
|
||||
| Admin selectie + bulk + verbe noi | `admin.html` + rute `/admin/*` | UI + **backend (status)** |
|
||||
| Model stare cont | `schema.sql`, `users.py`, worker gate | **backend + migrare** |
|
||||
229
docs/prd/prd-5.5-uniformizare-ui.md
Normal file
229
docs/prd/prd-5.5-uniformizare-ui.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# PRD 5.5 — Uniformizare & standardizare UI/UX
|
||||
|
||||
**Stare**: aprobat (2026-06-23)
|
||||
|
||||
> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> **Design vizual (sursa de adevar pentru *cum arata*)**: `docs/design/5.5-uniformizare-ui.md`.
|
||||
> Stare: `draft → aprobat → in-executie → verify-pass → inchis`.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Aducem toate suprafetele dashboard-ului la acelasi vocabular de componente ca tabelul **Trimiteri**
|
||||
(referinta corecta), reasezam navigarea intr-un **meniu de cont** (hamburger) si dam panoului admin
|
||||
actiuni reale de ciclu de viata pe conturi. Tinta: aplicatia arata si se comporta uniform, fara
|
||||
tabele labartate, fara wayfinding redundant, fara scroll orizontal pentru actiuni. Detaliile vizuale
|
||||
si deciziile utilizatorului: `docs/design/5.5-uniformizare-ui.md` (§10).
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **Fara redesign de estetica**: paleta/tipografia/tokenii din `base.html` (5.3) raman NESCHIMBATI la octet.
|
||||
- **Fara atingere a fluxului de trimitere**: worker (masina stari submissions, idempotenta, mapping-rezolvare)
|
||||
NEATINS, cu o singura exceptie controlata — gate-ul `claim_one` pe noua stare de cont (US-004), pastrand
|
||||
echivalenta `active=1 ⇔ status='active'`.
|
||||
- **Fara schimbare a semanticii `auto_send`**: comutatorul Auto/Manual ramane reskin la nivel de macro
|
||||
(`name="auto_send"`, semantica de prezenta). Zero atingere a parserelor `/mapari` si `/_import/...`.
|
||||
- **Fara rute noi de date / fara HTTP nou pe chei API**: lifecycle-ul conturilor e admin-only, sub
|
||||
`require_admin` + CSRF, exact ca rutele admin existente.
|
||||
- **Fara responsive/mobile nou** dincolo de ce ofera deja `.tablewrap` (scroll in card).
|
||||
- **Tabelul Trimiteri ramane neatins** — e referinta, nu tinta.
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
### US-001: Elimina sectiunea "Ajutor" din Acasa
|
||||
**Ca** operator **vreau** o pagina Acasa fara wayfinding redundant **pentru ca** linkurile Mapari/Coduri RAR
|
||||
sunt deja in navigare.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_acasa.html`, `tests/test_web_acasa.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_acasa.py` — `test_acasa_fara_sectiune_ajutor` (randul "Ajutor:" + linkurile
|
||||
inline lipsesc din HTML-ul Acasa)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Blocul `Ajutor: <a>Mapari</a> <a>Coduri RAR</a>` (liniile ~47-55) eliminat din `_acasa.html`.
|
||||
- [ ] Restul Acasa (upload, "Primii pasi", sectiunea Trimiteri) neschimbat.
|
||||
- [ ] `python3 -m pytest tests/test_web_acasa.py -q` verde.
|
||||
- **Verificare E2E**: browser HTMX pe `/` — Acasa nu mai afiseaza randul Ajutor; upload + Trimiteri intacte.
|
||||
|
||||
### US-002: Tabel Nomenclator cu aspectul tabelului Trimiteri
|
||||
**Ca** operator **vreau** ca nomenclatorul sa arate identic cu Trimiteri **pentru ca** consistenta reduce
|
||||
sarcina cognitiva.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_nomenclator.html`, `tests/test_web_nomenclator.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_nomenclator.py` — `test_nomenclator_grila_standard` (`.tablewrap` + `table`
|
||||
+ antet `th` standard + `.pill` pe cod; empty-state `.empty`)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Foloseste aceeasi structura `.tablewrap > table` cu antet `th` standard ca `_submissions.html`.
|
||||
- [ ] Codul prestatie ramane in `.pill`; coloanele aliniate, hover/aspect identice cu Trimiteri.
|
||||
- [ ] Empty-state pastrat (`Nomenclator gol...`), in `.empty`.
|
||||
- [ ] `python3 -m pytest tests/test_web_nomenclator.py -q` verde; AA light+dark (zero literali de culoare).
|
||||
- **Verificare E2E**: browser — Nomenclator si Trimiteri arata din aceeasi familie vizuala in dark si light.
|
||||
|
||||
### US-003: Macro `autosend_toggle` compact (Auto / Manual)
|
||||
**Ca** operator **vreau** un comutator scurt In coada, fara text repetat pe randuri **pentru ca** proza
|
||||
inline ingrasa randurile si impinge actiunile afara din ecran.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_macros.html`, `tests/test_web_macros.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_macros.py` — `test_autosend_compact` (macro-ul randeaza control Auto/Manual,
|
||||
pastreaza `name="auto_send" value="true"` + `form=` + starea `checked`, si NU mai contine propozitiile
|
||||
explicative "La fisierele viitoare..."/"Nebifat = ...")
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `autosend_toggle(form_id, checked)` randeaza un comutator compact etichetat **Auto** / **Manual**
|
||||
(radio sau switch), nowrap, fara propozitii inline.
|
||||
- [ ] Pastreaza EXACT `name="auto_send"`, `value="true"`, semantica de prezenta (bifat→true / absent→false),
|
||||
`form="{{form_id}}"`, `checked` reflecta `checked`.
|
||||
- [ ] Explicatia detaliata NU mai e in macro (se muta in panoul Ajutor, US-005). Tooltip scurt admis pe control.
|
||||
- [ ] `python3 -m pytest tests/test_web_macros.py tests/test_import_e2e.py -q` verde (parserele backend nealterate).
|
||||
- **Verificare E2E**: in fluxul import (mapcoloane) si in Mapari, comutatorul produce acelasi `auto_send`
|
||||
bool ca azi (queued vs needs_review neschimbat).
|
||||
|
||||
### US-004: Model de stare a contului (`accounts.status`) + gate worker
|
||||
**Ca** sistem **vreau** stari de cont distincte (pending/active/blocked/archived/deleted) **pentru ca**
|
||||
adminul are nevoie de blocare/arhivare/stergere, nu doar activ/inactiv.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare defensiva), `app/users.py`, `app/worker/...` (gate `claim_one`),
|
||||
`tests/test_account_status.py`, `tests/test_worker_*.py` (~5 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_account_status.py` — `test_migrare_deriva_status_din_active`,
|
||||
`test_blocked_nu_e_claimuit`, `test_archived_nu_e_claimuit`, `test_dev_id1_protejat`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Coloana `accounts.status` TEXT cu CHECK pe `{pending,active,blocked,archived,deleted}` (stergere = soft,
|
||||
`status='deleted'` + purjare de catre jobul de retentie T16); migrare **defensiva si idempotenta** (pattern
|
||||
`_migrate` ca la `is_admin`), derivata din `active` la prima rulare: `active=1→active`, altfel `pending`.
|
||||
- [ ] Helperi puri in `users.py`: `set_account_status(id, status)`, `delete_account(id)`, cu protectia
|
||||
contului dev `id=1` (ridica/ignora, nu corupe).
|
||||
- [ ] Worker `claim_one` gate-uieste pe `status='active'`, pastrand echivalenta cu `COALESCE(active,1)=1`
|
||||
de azi (conturile blocked/archived NU sunt claimuite).
|
||||
- [ ] `active` ramane consistent (`active=1 ⇔ status='active'`) cat timp coexista, fara regresie pe testele worker.
|
||||
- [ ] `python3 -m pytest -q` verde (suita completa).
|
||||
- **Verificare E2E**: marcheaza un cont `blocked` → submission-urile lui nu pleaca la RAR; `active` → pleaca.
|
||||
|
||||
### US-005: Tabel Mapari standardizat + panou Ajutor
|
||||
**Ca** operator **vreau** tabelele Mapari compacte ca Trimiteri, cu actiunile vizibile fara scroll si ajutor
|
||||
intr-un singur loc **pentru ca** acum sunt labartate si butoanele Salveaza/Sterge ies din ecran.
|
||||
|
||||
- **Depinde de**: US-003
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_mapari.py` — `test_mapari_grila_compacta` (coloane inguste nowrap, actiuni
|
||||
la dreapta), `test_mapari_ajutor_disclosure` (un singur `<details>`/link Ajutor in antet, fara proza pe randuri)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Cele 3 sectiuni (De rezolvat / Mapari salvate / Formate coloane) folosesc grila standard ca Trimiteri;
|
||||
coloana "In coada" foloseste macro-ul compact din US-003.
|
||||
- [ ] Butoanele **Salveaza**/**Sterge** vizibile fara scroll orizontal pe latime de dashboard normala
|
||||
(coloana Actiuni la dreapta, nowrap); sub-text (`N blocate`, `acum: COD`) ca `muted` 12px sub valoare.
|
||||
- [ ] Antetul "De rezolvat" contine un link/`<details>` **Ajutor** cu explicatia maparilor + Auto/Manual,
|
||||
scrisa O SINGURA DATA; proza inline de pe randuri eliminata.
|
||||
- [ ] CSRF, `hx-post`, `hx-target="#mapari-section"`, formularele si re-rezolvarea la edit cod — neschimbate.
|
||||
- [ ] `python3 -m pytest tests/test_web_mapari.py -q` verde; AA light+dark.
|
||||
- **Verificare E2E**: browser — mapezi o operatie (Salveaza vizibil fara scroll), comuti Auto/Manual, deschizi Ajutor;
|
||||
submission blocat se deblocheaza la salvarea codului (comportament neschimbat).
|
||||
|
||||
### US-006: Meniu hamburger in header + context de autentificare
|
||||
**Ca** utilizator **vreau** un meniu de cont in dreapta-sus cu Cont/Integrare/Nomenclator/Panou admin/logout
|
||||
**pentru ca** acestea nu sunt lucru zilnic si aglomereaza tab-bar-ul.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/base.html`, `app/web/routes.py` (helper context partajat),
|
||||
`tests/test_web_header_menu.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_header_menu.py` — `test_meniu_autentificat_are_linkuri_cont`,
|
||||
`test_meniu_admin_doar_pentru_admin`, `test_meniu_neautentificat_fara_logout` (login/signup → fara linkuri de cont)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Iconita `☰` in header (langa toggle tema), `aria-label`, `aria-expanded`, `aria-controls`; dropdown ancorat
|
||||
dreapta-sus; inchidere la click-afara + `Esc` (focus readus pe `☰`). Fara overlay.
|
||||
- [ ] Continut autentificat: Cont, Integrare, Nomenclator, **Panou admin** (doar `is_admin`), separator,
|
||||
**Iesi din cont** (form `POST /logout` cu `csrf_token`).
|
||||
- [ ] `base.html` primeste `is_authenticated`/`is_admin`/`csrf_token` printr-un helper de context partajat (un
|
||||
singur loc); **defensiv**: lipsa cheilor → meniu in stare neautentificata, nu eroare.
|
||||
- [ ] Pe login/signup meniul nu arata linkuri de cont/logout.
|
||||
- [ ] `python3 -m pytest tests/test_web_header_menu.py -q` verde.
|
||||
- **Verificare E2E**: browser — `☰` deschide/inchide (Esc + click-afara), linkurile navigheaza corect, logout iese;
|
||||
pe login meniul nu expune cont.
|
||||
|
||||
### US-007: Tab-bar redus la Acasa · Mapari
|
||||
**Ca** operator **vreau** un tab-bar doar cu suprafetele de lucru zilnic **pentru ca** Cont/Integrare/Nomenclator
|
||||
traiesc acum in meniul de cont.
|
||||
|
||||
- **Depinde de**: US-006
|
||||
- **Fisiere**: `app/web/templates/dashboard.html`, `tests/test_web_dashboard_tabs.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_dashboard_tabs.py` — `test_tabbar_doar_acasa_mapari`,
|
||||
`test_fragmente_mutate_inca_accesibile` (`/_fragments/{cont,integrare,nomenclator}` raman 200 + deep-link `?tab=`)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `tabs` in `dashboard.html` = doar `acasa`, `mapari`; badge-urile de contoare raman pe Mapari.
|
||||
- [ ] Logout + link admin ad-hoc din coltul dreapta-sus al dashboard-ului eliminate (mutate in meniul US-006).
|
||||
- [ ] Rutele `/_fragments/cont|integrare|nomenclator` + `?tab=` raman valide (accesate din meniu); zero rute moarte,
|
||||
zero 404 pe deep-link existent.
|
||||
- [ ] Navigarea ARIA cu sageti pe tab-bar ramane corecta cu 2 tab-uri.
|
||||
- [ ] `python3 -m pytest tests/test_web_dashboard_tabs.py -q` verde.
|
||||
- **Verificare E2E**: browser — tab-bar arata doar Acasa/Mapari; deschizi Nomenclator/Cont/Integrare din `☰`,
|
||||
deep-link `/?tab=integrare` inca functioneaza.
|
||||
|
||||
### US-008: Rute admin pentru ciclul de viata al conturilor (block/archive/delete + bulk)
|
||||
**Ca** admin **vreau** endpointuri care blocheaza/arhiveaza/sterg conturi, individual si in bulk **pentru ca**
|
||||
panoul are nevoie sa actioneze pe selectie.
|
||||
|
||||
- **Depinde de**: US-004
|
||||
- **Fisiere**: `app/web/routes.py` (rute `/admin/*`), `tests/test_admin_lifecycle.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_admin_lifecycle.py` — `test_block_archive_delete_single`,
|
||||
`test_bulk_pe_lista_account_id`, `test_bulk_sare_contul_dev`, `test_non_admin_403`, `test_csrf_obligatoriu`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Rute `POST /admin/block`, `/admin/archive`, `/admin/delete` (+ pastreaza `activate`) sub `require_admin` + CSRF,
|
||||
cu PRG (redirect inapoi la `/admin`), folosind helperii din US-004.
|
||||
- [ ] Accepta o LISTA de `account_id` (bulk) si o singura tinta (per-rand) prin acelasi handler.
|
||||
- [ ] Contul dev `id=1` e sarit in bulk (nu eroare) si refuzat individual; `delete` cere confirmare la nivel UI
|
||||
(US-009) si purjeaza datele conform retentiei (GDPR/L.142).
|
||||
- [ ] Non-admin → 403; lipsa CSRF → respins.
|
||||
- [ ] `python3 -m pytest tests/test_admin_lifecycle.py -q` verde.
|
||||
- **Verificare E2E**: POST autentificat ca admin pe fiecare verb (single + bulk) muta starea corect; contul dev neatins.
|
||||
|
||||
### US-009: Panou admin — selectie cu bife + bara bulk + actiuni per-rand
|
||||
**Ca** admin **vreau** sa selectez conturi si sa aplic actiuni pe selectie **pentru ca** activarea/blocarea una
|
||||
cate una e lenta.
|
||||
|
||||
- **Depinde de**: US-008
|
||||
- **Fisiere**: `app/web/templates/admin.html`, `tests/test_web_admin.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_admin.py` — `test_admin_coloana_selectie_si_master`,
|
||||
`test_bara_bulk_cu_cele_4_verbe`, `test_actiuni_per_rand`, `test_fara_nota_cont_dev`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Tabel conturi in asteptare (si analog active): coloana checkbox + master "Selecteaza tot"
|
||||
(`aria-label` per rand + master).
|
||||
- [ ] Bara de actiuni bulk (ascunsa pana la selectie) cu **Activeaza / Blocheaza / Arhiveaza / Sterge**;
|
||||
`Sterge` cu `hx-confirm`/dialog; trimite lista de `account_id` la rutele US-008.
|
||||
- [ ] Actiuni per-rand (kebab `...`) cu aceleasi verbe; `Sterge` cu `color:var(--err)` + confirmare.
|
||||
- [ ] Nota "Cont dev implicit (id=1)" **eliminata** din pagina (protectia ramane in cod, US-004/US-008).
|
||||
- [ ] `python3 -m pytest tests/test_web_admin.py -q` verde; AA light+dark; tabel in grila standard.
|
||||
- **Verificare E2E**: browser ca admin — bifezi 2 conturi, bara bulk apare cu numarul selectat, Arhiveaza muta randurile;
|
||||
Sterge cere confirmare; contul dev nu poate fi selectat-distrus.
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Schema `accounts.status` (US-004)** = singura schimbare de date. Mitigare: migrare defensiva idempotenta (pattern
|
||||
`_migrate` deja folosit la `accounts.active`/`users.is_admin`), derivata din `active`, cu echivalenta `active=1 ⇔
|
||||
status='active'` pana cand `active` poate fi retras intr-o livrabila viitoare. Testele worker existente sunt plasa.
|
||||
- **`base.html` partajat (US-006)**: e folosit de login/signup/admin/dashboard. Risc de context lipsa → meniu rupt.
|
||||
Mitigare: helper de context partajat + defaulturi defensive (lipsa → neautentificat), test pe toate cele 4 pagini.
|
||||
- **Coliziune pe fisiere intre stories** (lectia 5.1 clobber): US-006 si US-007 ating ambele zona de navigare
|
||||
(`base.html` vs `dashboard.html`, plus scoaterea logout-ului ad-hoc din `dashboard.html`). Mitigare: US-007 depinde
|
||||
de US-006 si ruleaza secvential (acelasi teammate recomandat), nu in worktree-uri paralele.
|
||||
- **`delete` cont (US-008)**: actiune distructiva ireversibila. Mitigare: confirmare UI obligatorie, contul dev protejat,
|
||||
purjare aliniata la retentia existenta (T16), nu stergere ad-hoc de date conexe fara plan.
|
||||
- **Macro autosend (US-003)**: orice schimbare de `name`/semantica ar rupe tacit clasificarea queued/needs_review.
|
||||
Mitigare: test care asereaza `name="auto_send"` + prezenta, plus `test_import_e2e` ramane verde.
|
||||
|
||||
## 5. Intrebari deschise — REZOLVATE (aprobare utilizator 2026-06-23)
|
||||
|
||||
- **Stergere cont** → **soft delete**: `status='deleted'`, scos imediat din toate listele, date purjate de jobul
|
||||
de retentie existent (T16, GDPR/L.142). NU hard DELETE imediat (auditabil + fereastra de revenire).
|
||||
- **Blocheaza vs Arhiveaza** → `blocked` = suspendare **reversibila**, contul ramane in liste, marcat vizibil,
|
||||
worker nu trimite; `archived` = **scos din listele active**, date pastrate read-only. Etichete confirmate.
|
||||
- **Comutatorul In coada** → **radio etichetat Auto / Manual** (explicit), nu switch on/off.
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] [US-002] [US-003] [US-004] ← fara dependente, fisiere disjuncte → paralel
|
||||
Val 2: [US-005] ← dep US-003 (macro)
|
||||
[US-006] ← fara dep (navigare/header)
|
||||
[US-008] ← dep US-004 (rute admin pe model stare)
|
||||
Val 3: [US-007] ← dep US-006 (acelasi fisier de navigare → secvential, NU worktree paralel)
|
||||
[US-009] ← dep US-008 (UI admin pe rutele de lifecycle)
|
||||
```
|
||||
@@ -102,12 +102,13 @@ def test_acasa_fara_linkuri_ajutor(client):
|
||||
assert 'href="/?tab=coada"' not in r.text
|
||||
|
||||
|
||||
def test_acasa_pastreaza_wayfinding_mapari_coduri(client):
|
||||
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'."""
|
||||
def test_acasa_fara_wayfinding_ajutor(client):
|
||||
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
|
||||
navigarea traieste in tab-bar si in meniul de cont."""
|
||||
r = client.get("/?tab=acasa")
|
||||
html = r.text
|
||||
assert 'href="/?tab=mapari"' in html
|
||||
assert "Coduri RAR" in html
|
||||
assert "Ajutor:" not in html
|
||||
assert "Coduri RAR" not in html
|
||||
|
||||
|
||||
def test_badge_trimiteri_scoped_pe_acasa(client):
|
||||
|
||||
160
tests/test_account_status.py
Normal file
160
tests/test_account_status.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
|
||||
|
||||
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
|
||||
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
|
||||
din listare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_status.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _status(conn, acct_id):
|
||||
return conn.execute("SELECT status, active FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
|
||||
|
||||
def test_create_account_activ_status_active(conn):
|
||||
from app.accounts import create_account
|
||||
acct_id = create_account(conn, "Service X", active=True)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == "active" and row["active"] == 1
|
||||
|
||||
|
||||
def test_create_account_inactiv_status_pending(conn):
|
||||
from app.accounts import create_account
|
||||
acct_id = create_account(conn, "Service Y", active=False)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == "pending" and row["active"] == 0
|
||||
|
||||
|
||||
def test_default_account_id1_active(conn):
|
||||
row = _status(conn, 1)
|
||||
assert row["status"] == "active" and row["active"] == 1
|
||||
|
||||
|
||||
def test_set_status_mentine_invariant_active(conn):
|
||||
from app.accounts import create_account, set_status
|
||||
acct_id = create_account(conn, "Service Z", active=True)
|
||||
for st, exp_active in [("blocked", 0), ("archived", 0), ("active", 1), ("pending", 0)]:
|
||||
set_status(conn, acct_id, st)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == st and row["active"] == exp_active
|
||||
|
||||
|
||||
def test_set_status_invalid_ridica(conn):
|
||||
from app.accounts import create_account, set_status
|
||||
acct_id = create_account(conn, "Service W", active=True)
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, acct_id, "inexistent")
|
||||
|
||||
|
||||
def test_set_status_cont_inexistent_ridica(conn):
|
||||
from app.accounts import set_status
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, 9999, "blocked")
|
||||
|
||||
|
||||
def test_set_active_mirror_status(conn):
|
||||
from app.accounts import create_account, set_active
|
||||
acct_id = create_account(conn, "Service M", active=True)
|
||||
set_active(conn, acct_id, False)
|
||||
assert _status(conn, acct_id)["status"] == "pending"
|
||||
set_active(conn, acct_id, True)
|
||||
assert _status(conn, acct_id)["status"] == "active"
|
||||
|
||||
|
||||
def test_delete_account_soft(conn):
|
||||
from app.accounts import create_account, delete_account, list_accounts
|
||||
acct_id = create_account(conn, "Service D", active=True)
|
||||
delete_account(conn, acct_id)
|
||||
assert _status(conn, acct_id)["status"] == "deleted"
|
||||
# exclus din listare
|
||||
assert all(a["id"] != acct_id for a in list_accounts(conn))
|
||||
|
||||
|
||||
def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
|
||||
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
|
||||
from app.accounts import create_account, delete_account, list_accounts
|
||||
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
|
||||
conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,))
|
||||
conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,))
|
||||
conn.commit()
|
||||
|
||||
delete_account(conn, acct_id)
|
||||
|
||||
row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?",
|
||||
(acct_id,)).fetchone()
|
||||
assert row["status"] == "deleted"
|
||||
assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere"
|
||||
assert row["cui"] is None, "CUI trebuie eliberat la stergere"
|
||||
key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone()
|
||||
assert key_active["active"] == 0, "cheile API trebuie revocate"
|
||||
# CUI eliberat -> se poate re-inregistra acelasi CUI
|
||||
new_id = create_account(conn, "Service Nou", cui="RO12345", active=True)
|
||||
assert new_id != acct_id
|
||||
assert all(a["id"] != acct_id for a in list_accounts(conn))
|
||||
|
||||
|
||||
def test_dev_id1_protejat_de_status_negativ(conn):
|
||||
from app.accounts import set_status, delete_account
|
||||
for verb in ("blocked", "archived", "deleted"):
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, 1, verb)
|
||||
with pytest.raises(ValueError):
|
||||
delete_account(conn, 1)
|
||||
# ramane activ
|
||||
assert _status(conn, 1)["status"] == "active"
|
||||
|
||||
|
||||
def test_migrare_deriva_status_din_active(conn):
|
||||
"""DB veche fara coloana status -> _migrate o adauga si o deriva din active.
|
||||
|
||||
Pornim de la schema reala (fixtura `conn` a rulat init_db), reconstruim tabela accounts
|
||||
FARA coloana status (simuleaza DB pre-5.5), apoi rulam _migrate.
|
||||
"""
|
||||
from app.db import _migrate
|
||||
|
||||
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
|
||||
conn.executescript(
|
||||
"""
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE accounts_legacy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT
|
||||
);
|
||||
INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at)
|
||||
SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts;
|
||||
DROP TABLE accounts;
|
||||
ALTER TABLE accounts_legacy RENAME TO accounts;
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO accounts (name, active) VALUES ('Activ', 1)")
|
||||
conn.execute("INSERT INTO accounts (name, active) VALUES ('Inactiv', 0)")
|
||||
conn.commit()
|
||||
assert "status" not in {r["name"] for r in conn.execute("PRAGMA table_info(accounts)")}
|
||||
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
rows = {r["name"]: r["status"] for r in conn.execute("SELECT name, status FROM accounts")}
|
||||
assert rows["default"] == "active" # id=1
|
||||
assert rows["Activ"] == "active"
|
||||
assert rows["Inactiv"] == "pending"
|
||||
@@ -112,4 +112,4 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
||||
assert ids == sorted(ids)
|
||||
for r in rows:
|
||||
assert "rar_creds_enc" not in r
|
||||
assert set(r.keys()) == {"id", "name", "cui", "active", "created_at"}
|
||||
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}
|
||||
|
||||
137
tests/test_admin_lifecycle.py
Normal file
137
tests/test_admin_lifecycle.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
|
||||
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_lifecycle.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csrf(client, url="/admin"):
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, f"csrf negasit in {url}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client, name, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/signup")
|
||||
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
|
||||
"csrf_token": tok}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email,)).fetchone()
|
||||
return int(row["account_id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_admin(account_id):
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/login")
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _status(account_id):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT status FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return row["status"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _admin_login(client):
|
||||
admin_id = _signup(client, "Admin SA", "admin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admin@test.ro")
|
||||
return admin_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verb,expected", [
|
||||
("block", "blocked"),
|
||||
("archive", "archived"),
|
||||
("delete", "deleted"),
|
||||
])
|
||||
def test_lifecycle_single(client, verb, expected):
|
||||
target = _signup(client, "Tinta SRL", "tinta@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
resp = client.post(f"/admin/{verb}", data={"account_id": target, "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(target) == expected
|
||||
|
||||
|
||||
def test_bulk_pe_lista_account_id(client):
|
||||
a = _signup(client, "A SRL", "a@test.ro")
|
||||
b = _signup(client, "B SRL", "b@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
resp = client.post("/admin/block", data={"account_id": [a, b], "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(a) == "blocked" and _status(b) == "blocked"
|
||||
|
||||
|
||||
def test_bulk_sare_contul_dev(client):
|
||||
target = _signup(client, "Tinta SRL", "t2@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
# include id=1 (cont de sistem) in selectie -> sarit, fara eroare; tinta procesata
|
||||
resp = client.post("/admin/archive", data={"account_id": [1, target], "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(1) == "active", "contul dev id=1 trebuie sa ramana neatins"
|
||||
assert _status(target) == "archived"
|
||||
|
||||
|
||||
def test_non_admin_403(client):
|
||||
target = _signup(client, "Tinta SRL", "t3@test.ro")
|
||||
_signup(client, "Neadmin SRL", "plain@test.ro")
|
||||
_login(client, "plain@test.ro")
|
||||
# csrf de pe o pagina accesibila non-admin
|
||||
tok = _csrf(client, "/")
|
||||
resp = client.post("/admin/block", data={"account_id": target, "csrf_token": tok})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_csrf_obligatoriu(client):
|
||||
target = _signup(client, "Tinta SRL", "t4@test.ro")
|
||||
_admin_login(client)
|
||||
resp = client.post("/admin/delete", data={"account_id": target}) # fara csrf_token
|
||||
assert resp.status_code != 303
|
||||
assert _status(target) != "deleted"
|
||||
@@ -103,13 +103,13 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
|
||||
# --- markup / copy ---
|
||||
|
||||
def test_comutator_coada_prezent():
|
||||
"""Textul tinteste COADA ("in coada"/"verificare"), NU "trimite"/"Manual" gol."""
|
||||
"""5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact.
|
||||
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
|
||||
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
|
||||
html = _macro_html()
|
||||
assert "in coada" in html, "comutatorul trebuie sa vorbeasca despre coada"
|
||||
assert "verificare" in html, "optiunea de verificare manuala trebuie prezenta"
|
||||
assert "name=\"auto_send\"" in html and 'value="true"' in html
|
||||
# framing periculos interzis (citit global = send-safety):
|
||||
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)"
|
||||
assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
|
||||
assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
|
||||
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
|
||||
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
|
||||
|
||||
@@ -179,8 +179,9 @@ def test_comutator_in_tab_mapari(client):
|
||||
_login(client, "tm@test.com")
|
||||
resp = client.get("/?tab=mapari")
|
||||
assert resp.status_code == 200
|
||||
assert "Pune automat in coada" in resp.text
|
||||
assert "aceasta operatie" in resp.text
|
||||
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
|
||||
assert 'name="auto_send"' in resp.text
|
||||
assert "Manual" in resp.text and "Auto" in resp.text
|
||||
|
||||
|
||||
def test_comutator_in_panou_preview(client):
|
||||
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
||||
assert "Pune automat in coada" in r.text
|
||||
assert "aceasta operatie" in r.text
|
||||
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
|
||||
assert 'name="auto_send"' in r.text
|
||||
assert "Manual" in r.text and "Auto" in r.text
|
||||
|
||||
128
tests/test_web_admin.py
Normal file
128
tests/test_web_admin.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Teste US-009 (PRD 5.5): panou admin UI — selectie cu bife + master, bara de actiuni bulk
|
||||
(Activeaza/Blocheaza/Arhiveaza/Sterge), actiuni per-rand, fara nota 'cont dev implicit',
|
||||
grila standard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_web_admin.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csrf(client, url):
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client, name, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/signup")
|
||||
client.post("/signup", data={"name": name, "email": email, "parola": password,
|
||||
"csrf_token": tok}, follow_redirects=True)
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email,)).fetchone()
|
||||
return int(row["account_id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _admin_login(client):
|
||||
target = _signup(client, "Pending SRL", "pending@test.ro") # cont in asteptare
|
||||
admin_id = _signup(client, "Admin SA", "admin@test.ro")
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, admin_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
tok = _csrf(client, "/login")
|
||||
resp = client.post("/login", data={"email": "admin@test.ro", "parola": "parola_test_001",
|
||||
"csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
return target
|
||||
|
||||
|
||||
def test_admin_coloana_selectie_si_master(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
# checkbox de selectie pe rand + master
|
||||
assert 'name="account_id"' in html
|
||||
assert 'type="checkbox"' in html
|
||||
assert "Selecteaza tot" in html or 'data-master' in html
|
||||
|
||||
|
||||
def test_bara_bulk_cu_cele_4_verbe(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert 'formaction="/admin/activate"' in html
|
||||
assert 'formaction="/admin/block"' in html
|
||||
assert 'formaction="/admin/archive"' in html
|
||||
assert 'formaction="/admin/delete"' in html
|
||||
# bara e ascunsa initial (hidden), fara display inline care ar invinge [hidden]
|
||||
assert re.search(r'class="bulk-bar"\s+hidden', html) or re.search(r'hidden[^>]*class="bulk-bar"', html)
|
||||
assert "bulk-bar" in html and ".bulk-bar[hidden]" in html # CSS care face hidden eficient
|
||||
|
||||
|
||||
def test_actiuni_per_rand(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
# forme per-rand catre rutele de lifecycle (kebab)
|
||||
assert 'action="/admin/block"' in html
|
||||
assert 'action="/admin/archive"' in html
|
||||
assert 'action="/admin/delete"' in html
|
||||
|
||||
|
||||
def test_fara_nota_cont_dev(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert "cont dev implicit" not in html.lower()
|
||||
assert "Cont dev implicit" not in html
|
||||
|
||||
|
||||
def test_grila_standard(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert "tablewrap" in html
|
||||
|
||||
|
||||
def test_cont_arhivat_in_blocul_suspendate(client):
|
||||
"""Gruparea pe STARE: un cont arhivat apare in blocul blocate/arhivate, nu in 'in asteptare'."""
|
||||
target = _admin_login(client) # cont pending seedat
|
||||
from app.db import get_connection
|
||||
from app.accounts import set_status
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_status(conn, target, "archived")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
html = client.get("/admin").text
|
||||
# contul arhivat ajunge in blocul suspendate (1 cont), nu in "in asteptare"
|
||||
assert re.search(r"Conturi blocate / arhivate \(1\)", html)
|
||||
assert ">archived<" in html
|
||||
86
tests/test_web_header_menu.py
Normal file
86
tests/test_web_header_menu.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Teste US-006 (PRD 5.5): meniu hamburger in header (Cont/Integrare/Nomenclator/Admin/logout)
|
||||
+ context de autentificare. base.html partajat: pe login/signup meniul nu expune cont/logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "menu_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()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _make_user(email="u@test.com", password="parolasecreta10", is_admin=False):
|
||||
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", active=True)
|
||||
create_user(conn, acct_id, email, password, is_admin=is_admin)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email="u@test.com", password="parolasecreta10"):
|
||||
resp = client.get("/login")
|
||||
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def test_meniu_autentificat_are_linkuri_cont(client):
|
||||
_make_user()
|
||||
_login(client)
|
||||
html = client.get("/").text
|
||||
# butonul de meniu (hamburger) prezent
|
||||
assert 'id="cont-menu-toggle"' in html
|
||||
assert 'aria-controls="cont-menu"' in html
|
||||
# linkurile mutate in meniu
|
||||
assert 'href="/?tab=cont"' in html
|
||||
assert 'href="/?tab=integrare"' in html
|
||||
assert 'href="/?tab=nomenclator"' in html
|
||||
# logout in meniu
|
||||
assert 'action="/logout"' in html
|
||||
assert "Iesi din cont" in html
|
||||
|
||||
|
||||
def test_meniu_admin_link_doar_pentru_admin(client):
|
||||
_make_user(email="admin@test.com", is_admin=True)
|
||||
_login(client, email="admin@test.com")
|
||||
html = client.get("/").text
|
||||
assert 'href="/admin"' in html
|
||||
|
||||
|
||||
def test_meniu_fara_admin_pentru_neadmin(client):
|
||||
_make_user(email="plain@test.com", is_admin=False)
|
||||
_login(client, email="plain@test.com")
|
||||
html = client.get("/").text
|
||||
assert 'href="/admin"' not in html
|
||||
|
||||
|
||||
def test_meniu_neautentificat_fara_logout(client):
|
||||
"""Pe /login (neautentificat) meniul nu expune cont/logout."""
|
||||
html = client.get("/login").text
|
||||
assert "Iesi din cont" not in html
|
||||
assert 'action="/logout"' not in html
|
||||
assert 'id="cont-menu-toggle"' not in html
|
||||
@@ -28,12 +28,17 @@ def _starile_din_schema() -> list[str]:
|
||||
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.
|
||||
# Nota (5.5): de cand exista si `accounts.status` cu propriul CHECK, ancoram pe blocul
|
||||
# tabelei submissions (`CREATE TABLE ... submissions`) ca sa nu prindem starile de cont.
|
||||
tbl = re.search(
|
||||
r"CREATE TABLE[^;]*?submissions\b(.*?);", sql, re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
assert tbl, "Nu am gasit CREATE TABLE submissions in schema.sql — schema s-a schimbat?"
|
||||
match = re.search(
|
||||
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
|
||||
sql,
|
||||
tbl.group(1),
|
||||
)
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in submissions — schema s-a schimbat?"
|
||||
|
||||
raw = match.group(1)
|
||||
# Extrage valorile dintre ghilimele simple
|
||||
|
||||
@@ -73,7 +73,7 @@ def client(monkeypatch):
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
|
||||
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
|
||||
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
|
||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
||||
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
|
||||
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
|
||||
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
|
||||
# Doar Acasa + Mapari sunt tab-uri (role="tab")
|
||||
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
|
||||
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
|
||||
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
|
||||
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
|
||||
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
|
||||
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
|
||||
# ...dar traiesc in meniul de cont
|
||||
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -215,3 +218,20 @@ def test_tabbar_aria(client):
|
||||
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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_fragmentele_mutate_raman_accesibile (US-007)
|
||||
# ============================================================
|
||||
|
||||
def test_fragmentele_mutate_raman_accesibile(client):
|
||||
"""US-007 (5.5): Cont/Integrare/Nomenclator s-au mutat in meniu, dar rutele de fragment
|
||||
si deep-link-ul ?tab= raman valide (zero rute moarte / 404)."""
|
||||
_create_account_user("frag@test.com", "parolasecreta10")
|
||||
_login(client, "frag@test.com", "parolasecreta10")
|
||||
|
||||
for tab in ("cont", "integrare", "nomenclator"):
|
||||
r_frag = client.get(f"/_fragments/{tab}")
|
||||
assert r_frag.status_code == 200, f"/_fragments/{tab} a dat {r_frag.status_code}"
|
||||
r_deep = client.get(f"/?tab={tab}")
|
||||
assert r_deep.status_code == 200, f"/?tab={tab} a dat {r_deep.status_code}"
|
||||
|
||||
171
tests/test_web_uniformizare.py
Normal file
171
tests/test_web_uniformizare.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
|
||||
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja
|
||||
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
_TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "web" / "templates"
|
||||
|
||||
|
||||
def _env():
|
||||
return Environment(loader=FileSystemLoader(str(_TEMPLATES)), autoescape=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-002: Nomenclator ca tabel standard (grila Trimiteri)
|
||||
# ============================================================
|
||||
|
||||
def test_nomenclator_grila_standard_cu_randuri():
|
||||
tmpl = _env().get_template("_nomenclator.html")
|
||||
html = tmpl.render(rows=[
|
||||
{"cod_prestatie": "A012", "nume_prestatie": "Revizie tehnica", "updated_at": "2026-06-20"},
|
||||
])
|
||||
assert "tablewrap" in html
|
||||
assert "<table" in html
|
||||
assert 'class="pill"' in html # codul in pill, ca la Trimiteri
|
||||
assert "A012" in html and "Revizie tehnica" in html
|
||||
|
||||
|
||||
def test_nomenclator_empty_state():
|
||||
tmpl = _env().get_template("_nomenclator.html")
|
||||
html = tmpl.render(rows=[])
|
||||
assert 'class="empty"' in html
|
||||
assert "Nomenclator gol" in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-003: macro autosend_toggle compact (Auto/Manual)
|
||||
# ============================================================
|
||||
|
||||
def _render_macro(form_id="map-1", checked=True):
|
||||
mod = _env().get_template("_macros.html").module
|
||||
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
|
||||
|
||||
|
||||
def test_autosend_pastreaza_name_si_prezenta():
|
||||
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta)."""
|
||||
html = _render_macro(checked=True)
|
||||
assert 'type="checkbox"' in html
|
||||
assert 'name="auto_send"' in html
|
||||
assert 'value="true"' in html
|
||||
assert 'form="map-1"' in html
|
||||
assert "checked" in html
|
||||
|
||||
|
||||
def test_autosend_nebifat_fara_checked():
|
||||
html = _render_macro(checked=False)
|
||||
assert 'name="auto_send"' in html
|
||||
assert "checked" not in html
|
||||
|
||||
|
||||
def test_autosend_compact_fara_proza_inline():
|
||||
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in
|
||||
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
|
||||
inainte de verificare."""
|
||||
html = _render_macro()
|
||||
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip)
|
||||
assert "La fisierele viitoare" not in vizibil
|
||||
assert "Tine pentru verificare" not in vizibil
|
||||
assert "nimic nu pleaca la RAR" not in vizibil
|
||||
# ambele etichete de stare vizibile, compact
|
||||
assert "Auto" in html and "Manual" in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-001: Acasa fara sectiunea Ajutor
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "uniform_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()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _login(client, email="u@test.com", password="parolasecreta10"):
|
||||
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", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
finally:
|
||||
conn.close()
|
||||
resp = client.get("/login")
|
||||
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
|
||||
assert resp.status_code == 303
|
||||
return acct_id
|
||||
|
||||
|
||||
def test_acasa_fara_sectiune_ajutor(client):
|
||||
_login(client)
|
||||
resp = client.get("/_fragments/acasa")
|
||||
assert resp.status_code == 200
|
||||
# randul "Ajutor:" cu wayfinding Mapari/Coduri RAR eliminat din Acasa
|
||||
assert "Ajutor:" not in resp.text
|
||||
assert "Coduri RAR" not in resp.text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-005: Tabel Mapari standardizat + panou Ajutor
|
||||
# ============================================================
|
||||
|
||||
def _seed_needs_mapping(acct_id, cod_op="OP-NM", denumire="Operatie test"):
|
||||
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 (?, ?, 'needs_mapping', ?)",
|
||||
(f"k-{cod_op}", acct_id,
|
||||
json.dumps({"prestatii": [{"cod_op_service": cod_op, "denumire": denumire}]})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
|
||||
acct = _login(client)
|
||||
_seed_needs_mapping(acct)
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# panou Ajutor (<details>) prezent
|
||||
assert "ajutor-mapari" in html
|
||||
assert "<details" in html and ">Ajutor<" in html
|
||||
# antet de coloana compact
|
||||
assert ">In coada<" in html
|
||||
# proza inline veche eliminata de pe sectiuni
|
||||
assert "sugestia fuzzy e preselectata) si salveaza" not in html
|
||||
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
|
||||
|
||||
|
||||
def test_mapari_comutator_compact_in_tabel(client):
|
||||
acct = _login(client)
|
||||
_seed_needs_mapping(acct)
|
||||
html = client.get("/_fragments/mapari").text
|
||||
assert 'name="auto_send"' in html
|
||||
assert "Manual" in html and "Auto" in html
|
||||
@@ -125,6 +125,51 @@ def test_claim_account_null_tratat_activ(env):
|
||||
assert _row_status(conn, sid) == "sending"
|
||||
|
||||
|
||||
def test_claim_sare_cont_blocat(env):
|
||||
"""5.5: cont blocked -> claim_one nu ridica submission-ul."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Blocat", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "blocked")
|
||||
|
||||
assert claim_one(conn) is None
|
||||
assert _row_status(conn, sid) == "queued"
|
||||
|
||||
|
||||
def test_claim_sare_cont_arhivat(env):
|
||||
"""5.5: cont archived -> claim_one nu ridica submission-ul."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Arhivat", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "archived")
|
||||
|
||||
assert claim_one(conn) is None
|
||||
assert _row_status(conn, sid) == "queued"
|
||||
|
||||
|
||||
def test_deblocare_reia_trimiterea(env):
|
||||
"""5.5: blocked -> set_status('active') -> claim_one ridica din nou."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Revenit", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "blocked")
|
||||
assert claim_one(conn) is None
|
||||
|
||||
set_status(conn, acct_id, "active")
|
||||
result = claim_one(conn)
|
||||
assert result is not None and result["id"] == sid
|
||||
assert _row_status(conn, sid) == "sending"
|
||||
|
||||
|
||||
def test_claim_cont_legacy_fara_active(env):
|
||||
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
|
||||
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.
|
||||
|
||||
Reference in New Issue
Block a user