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)")
|
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||||
cui = _norm_cui(cui)
|
cui = _norm_cui(cui)
|
||||||
try:
|
try:
|
||||||
|
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)",
|
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
|
||||||
(name, cui, 1 if active else 0),
|
(name, cui, 1 if active else 0, "active" if active else "pending"),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
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:
|
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
|
||||||
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
"""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()
|
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError(f"cont inexistent: {account_id}")
|
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]:
|
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(
|
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()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
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:
|
if "active" not in acc_cols:
|
||||||
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
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.
|
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
"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,
|
name TEXT NOT NULL,
|
||||||
cui TEXT,
|
cui TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
|
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)
|
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
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 ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
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)
|
emails = _emails_by_account(conn)
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
acct["email"] = emails.get(acct["id"])
|
acct["email"] = emails.get(acct["id"])
|
||||||
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
|
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||||
active = [a for a in accounts if a["active"] and a["id"] != 1]
|
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||||
default = next((a for a in accounts if a["id"] == 1), None)
|
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(
|
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
|
||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
pending=pending,
|
pending=pending,
|
||||||
active=active,
|
active=active,
|
||||||
default_account=default,
|
suspended=suspended,
|
||||||
error=error,
|
error=error,
|
||||||
|
is_authenticated=True,
|
||||||
|
is_admin=True,
|
||||||
), status_code=status_code)
|
), status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,28 +78,66 @@ async def admin_get(request: Request):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
||||||
async def admin_activate(
|
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
|
||||||
request: Request,
|
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
|
||||||
account_id: int = Form(...),
|
`action`: activate | block | archive | delete."""
|
||||||
csrf_token: str = Form(default=""),
|
for aid in ids:
|
||||||
):
|
try:
|
||||||
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
|
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)
|
require_admin(request)
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
try:
|
_apply_lifecycle(conn, account_id, action)
|
||||||
set_active(conn, account_id, True)
|
conn.commit()
|
||||||
except ValueError as exc:
|
|
||||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return RedirectResponse("/admin", status_code=303)
|
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)
|
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||||
async def admin_deactivate(
|
async def admin_deactivate(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
|||||||
"active_tab": active_tab,
|
"active_tab": active_tab,
|
||||||
"panel_html": panel_html,
|
"panel_html": panel_html,
|
||||||
"badges": badges,
|
"badges": badges,
|
||||||
|
"is_authenticated": True,
|
||||||
"is_admin": is_account_admin(conn, account_id),
|
"is_admin": is_account_admin(conn, account_id),
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === Subordonat: ajutor rapid pe un rand discret ===
|
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
|
||||||
US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos
|
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
|
||||||
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>
|
|
||||||
|
|
||||||
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
|
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
|
||||||
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,
|
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. #}
|
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
||||||
|
|
||||||
{# US-007: comutator pe COADA in loc de bifa "auto-send".
|
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
|
||||||
Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele
|
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
|
||||||
poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu
|
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
|
||||||
semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca
|
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
|
||||||
bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get())
|
|
||||||
la /_import/.../mapare-operatie). Zero atingere backend.
|
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).
|
- 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) -%}
|
{% macro autosend_toggle(form_id='', checked=True) -%}
|
||||||
<div class="autosend-toggle" style="display:flex; flex-direction:column; gap:4px;">
|
<label class="autosend-toggle"
|
||||||
<span class="muted" style="font-size:12px;">La fisierele viitoare cu aceasta operatie:</span>
|
title="Auto = pune automat in coada la fisierele viitoare cu aceasta operatie. Manual = tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
||||||
<label class="chk" style="display:inline-flex; align-items:center; gap:8px; min-height:44px;">
|
style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; min-height:36px; cursor:pointer; font-size:13px;">
|
||||||
<input type="checkbox" name="auto_send" value="true"
|
<span class="muted">Manual</span>
|
||||||
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
<input type="checkbox" name="auto_send" value="true"
|
||||||
{%- if checked %} checked{% endif %}
|
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
||||||
aria-label="Pune automat in coada la fisierele viitoare cu aceasta operatie">
|
{%- if checked %} checked{% endif %}
|
||||||
<span><strong>Pune automat in coada</strong></span>
|
aria-label="In coada: Auto (bifat) sau Manual (nebifat), pentru aceasta operatie"
|
||||||
</label>
|
style="width:32px; height:18px; cursor:pointer; accent-color:var(--accent);">
|
||||||
<span class="muted" style="font-size:11px;">
|
<span><strong>Auto</strong></span>
|
||||||
Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie;
|
</label>
|
||||||
nimic nu pleaca la RAR pana confirmi.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -9,7 +9,22 @@
|
|||||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<div class="card">
|
<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 %}
|
{% if not pending %}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -17,18 +32,13 @@
|
|||||||
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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">
|
<div class="tablewrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Operatie</th>
|
<th>Operatie</th>
|
||||||
<th>Sugestii</th>
|
<th>Sugestii</th>
|
||||||
<th>Cod RAR</th>
|
<th>Cod RAR</th>
|
||||||
<th>Punere in coada</th>
|
<th>In coada</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -88,17 +98,14 @@
|
|||||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Operatie</th>
|
<th>Operatie</th>
|
||||||
<th>Cod RAR</th>
|
<th>Cod RAR</th>
|
||||||
<th>Punere in coada</th>
|
<th>In coada</th>
|
||||||
<th>Actiuni</th>
|
<th>Actiuni</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -160,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="tablewrap">
|
<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 %}
|
{% if rows %}
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="pill">{{ r.cod_prestatie }}</span></td>
|
<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>
|
<td class="muted">{{ r.updated_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,6 +1,104 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
|
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
|
||||||
{% block content %}
|
{% 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;">
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
|
||||||
<h2 style="margin:0;">Panou admin</h2>
|
<h2 style="margin:0;">Panou admin</h2>
|
||||||
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
|
<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>
|
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Conturi in asteptare -->
|
{{ lifecycle_block("Conturi in asteptare", pending, "pending",
|
||||||
<div class="card">
|
['activate', 'block', 'archive', 'delete'],
|
||||||
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
|
['activate', 'block', 'archive', 'delete']) }}
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<!-- Conturi active -->
|
{{ lifecycle_block("Conturi active", active, "active",
|
||||||
<div class="card">
|
['block', 'archive', 'delete'],
|
||||||
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
|
['block', 'archive', 'delete']) }}
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<!-- Contul dev default (id=1) -->
|
{# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
|
||||||
{% if default_account %}
|
{{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
|
||||||
<div class="card" style="border-color:var(--muted);">
|
['activate', 'delete'],
|
||||||
<p class="muted" style="margin:0;font-size:13px;">
|
['activate', 'delete']) }}
|
||||||
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
|
|
||||||
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
|
<script>
|
||||||
</p>
|
(function() {
|
||||||
</div>
|
// Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
|
||||||
{% endif %}
|
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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -116,6 +116,22 @@
|
|||||||
.eroare-3n-label { font-weight:500; }
|
.eroare-3n-label { font-weight:500; }
|
||||||
/* Inline fix per camp in preview */
|
/* Inline fix per camp in preview */
|
||||||
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -123,11 +139,30 @@
|
|||||||
<h1>Gateway RAR AUTOPASS</h1>
|
<h1>Gateway RAR AUTOPASS</h1>
|
||||||
<span class="env">{{ rar_env }}</span>
|
<span class="env">{{ rar_env }}</span>
|
||||||
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
|
<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)"
|
aria-label="Comuta tema (luminos/intunecat)"
|
||||||
title="Comuta tema"
|
title="Comuta tema">☀</button>
|
||||||
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>
|
|
||||||
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
@@ -165,5 +200,37 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<!-- Nav cont: link admin (doar pentru admini) + logout -->
|
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
|
||||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
|
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
||||||
<div id="status-bar" class="status-bar card"
|
<div id="status-bar" class="status-bar card"
|
||||||
@@ -20,14 +14,12 @@
|
|||||||
|
|
||||||
<!-- Tab-bar: navigare intre sectiuni -->
|
<!-- Tab-bar: navigare intre sectiuni -->
|
||||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||||
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc
|
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
|
||||||
ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #}
|
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
|
||||||
|
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
|
||||||
{% set tabs = [
|
{% set tabs = [
|
||||||
("acasa", "Acasa", "tab-acasa"),
|
("acasa", "Acasa", "tab-acasa"),
|
||||||
("mapari", "Mapari", "tab-mapari"),
|
("mapari", "Mapari", "tab-mapari")
|
||||||
("cont", "Cont", "tab-cont"),
|
|
||||||
("nomenclator", "Nomenclator", "tab-nomenclator"),
|
|
||||||
("integrare", "Integrare", "tab-integrare")
|
|
||||||
] %}
|
] %}
|
||||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||||
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
|
{% 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 "
|
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||||
"WHERE s.status='queued' "
|
"WHERE s.status='queued' "
|
||||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
"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",
|
"ORDER BY s.id LIMIT 1",
|
||||||
(_iso(_now()),),
|
(_iso(_now()),),
|
||||||
).fetchone()
|
).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
|
assert 'href="/?tab=coada"' not in r.text
|
||||||
|
|
||||||
|
|
||||||
def test_acasa_pastreaza_wayfinding_mapari_coduri(client):
|
def test_acasa_fara_wayfinding_ajutor(client):
|
||||||
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'."""
|
"""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")
|
r = client.get("/?tab=acasa")
|
||||||
html = r.text
|
html = r.text
|
||||||
assert 'href="/?tab=mapari"' in html
|
assert "Ajutor:" not in html
|
||||||
assert "Coduri RAR" in html
|
assert "Coduri RAR" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_badge_trimiteri_scoped_pe_acasa(client):
|
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)
|
assert ids == sorted(ids)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
assert "rar_creds_enc" not in r
|
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 ---
|
# --- markup / copy ---
|
||||||
|
|
||||||
def test_comutator_coada_prezent():
|
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()
|
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
|
assert "name=\"auto_send\"" in html and 'value="true"' in html
|
||||||
# framing periculos interzis (citit global = send-safety):
|
assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
|
||||||
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)"
|
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 "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
|
||||||
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
|
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")
|
_login(client, "tm@test.com")
|
||||||
resp = client.get("/?tab=mapari")
|
resp = client.get("/?tab=mapari")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "Pune automat in coada" in resp.text
|
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
|
||||||
assert "aceasta operatie" in resp.text
|
assert 'name="auto_send"' in resp.text
|
||||||
|
assert "Manual" in resp.text and "Auto" in resp.text
|
||||||
|
|
||||||
|
|
||||||
def test_comutator_in_panou_preview(client):
|
def test_comutator_in_panou_preview(client):
|
||||||
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
|
|||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
||||||
assert "Pune automat in coada" in r.text
|
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
|
||||||
assert "aceasta operatie" in r.text
|
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")
|
sql = schema_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
|
# 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(
|
match = re.search(
|
||||||
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
|
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)
|
raw = match.group(1)
|
||||||
# Extrage valorile dintre ghilimele simple
|
# Extrage valorile dintre ghilimele simple
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def client(monkeypatch):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def test_dashboard_are_tabbar(client):
|
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")
|
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||||
|
|
||||||
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
|
|||||||
html = resp.text
|
html = resp.text
|
||||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||||
|
|
||||||
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
|
# Doar Acasa + Mapari sunt tab-uri (role="tab")
|
||||||
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
|
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
|
||||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
|
||||||
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
|
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
|
||||||
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
|
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
|
||||||
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
|
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 'role="tabpanel"' in html, "Lipseste role=tabpanel"
|
||||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
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"
|
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"
|
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):
|
def test_claim_cont_legacy_fara_active(env):
|
||||||
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
|
"""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.
|
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.
|
||||||
|
|||||||
Reference in New Issue
Block a user