diff --git a/app/accounts.py b/app/accounts.py index 9781956..36e0103 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -39,9 +39,10 @@ def create_account( raise ValueError("name gol (un cont are nevoie de nume)") cui = _norm_cui(cui) try: + # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. cur = conn.execute( - "INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)", - (name, cui, 1 if active else 0), + "INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)", + (name, cui, 1 if active else 0, "active" if active else "pending"), ) except sqlite3.IntegrityError: existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() @@ -55,16 +56,72 @@ def create_account( def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None: """Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca). - Cont inexistent -> ValueError.""" + Cont inexistent -> ValueError. + + Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active', + dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere + foloseste `set_status`/`delete_account`. + """ row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() if not row: raise ValueError(f"cont inexistent: {account_id}") - conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id)) + conn.execute( + "UPDATE accounts SET active=?, status=? WHERE id=?", + (1 if active else 0, "active" if active else "pending", account_id), + ) + + +# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de +# retentie); restul sunt reversibile. +VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted") +# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b). +_PROTECTED_ACCOUNT_ID = 1 + + +def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None: + """Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active` + (active=1 doar pentru 'active', altfel 0). + + Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError. + Status invalid sau cont inexistent -> ValueError. + """ + if status not in VALID_STATUSES: + raise ValueError(f"status invalid: {status}") + row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() + if not row: + raise ValueError(f"cont inexistent: {account_id}") + if account_id == _PROTECTED_ACCOUNT_ID and status != "active": + raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).") + conn.execute( + "UPDATE accounts SET active=?, status=? WHERE id=?", + (1 if status == "active" else 0, status, account_id), + ) + + +def delete_account(conn: sqlite3.Connection, account_id: int) -> None: + """Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele + sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API + revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic + `ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat. + + Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram + tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches, + NU acest tombstone — de aceea purjam PII aici, la momentul stergerii.""" + set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0 + conn.execute( + "UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,) + ) + conn.execute( + "UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1", + (account_id,), + ) def list_accounts(conn: sqlite3.Connection) -> list[dict]: - """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id.""" + """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' + (stergere soft -> invizibile in panou).""" rows = conn.execute( - "SELECT id, name, cui, active, created_at FROM accounts ORDER BY id" + "SELECT id, name, cui, active, status, created_at FROM accounts " + "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows] diff --git a/app/db.py b/app/db.py index 1bce0f8..66e9abe 100644 --- a/app/db.py +++ b/app/db.py @@ -63,6 +63,18 @@ def _migrate(conn: sqlite3.Connection) -> None: if "active" not in acc_cols: # Conturi existente raman active (default 1). Lifecycle consumat de 3.3. conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1") + acc_cols.add("active") + if "status" not in acc_cols: + # Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b). + # Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`: + # active=0 -> 'pending'. Invariant: active=1 <=> status='active'. + conn.execute( + "ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' " + "CHECK (status IN ('pending','active','blocked','archived','deleted'))" + ) + conn.execute( + "UPDATE accounts SET status='pending' WHERE active=0 AND status='active'" + ) # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" diff --git a/app/schema.sql b/app/schema.sql index 1fb5e60..720dfc4 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -11,6 +11,13 @@ CREATE TABLE IF NOT EXISTS accounts ( name TEXT NOT NULL, cui TEXT, active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3 + -- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul + -- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status. + -- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste, + -- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere, + -- vezi accounts.delete_account — randul ramane doar pentru audit). + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('pending','active','blocked','archived','deleted')), rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1) created_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/app/web/admin_routes.py b/app/web/admin_routes.py index 02cf0d1..de6eb68 100644 --- a/app/web/admin_routes.py +++ b/app/web/admin_routes.py @@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import __version__ -from ..accounts import list_accounts, set_active +from ..accounts import list_accounts, set_active, set_status, delete_account from ..config import get_settings from ..db import get_connection from ..web.csrf import get_csrf_token, verify_csrf @@ -49,16 +49,20 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co emails = _emails_by_account(conn) for acct in accounts: acct["email"] = emails.get(acct["id"]) - pending = [a for a in accounts if not a["active"] and a["id"] != 1] - active = [a for a in accounts if a["active"] and a["id"] != 1] - default = next((a for a in accounts if a["id"] == 1), None) + # Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0) + # ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts. + pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1] + active = [a for a in accounts if a["status"] == "active" and a["id"] != 1] + suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1] return _TMPL.TemplateResponse(request, "admin.html", _ctx( request, csrf_token=get_csrf_token(request), pending=pending, active=active, - default_account=default, + suspended=suspended, error=error, + is_authenticated=True, + is_admin=True, ), status_code=status_code) @@ -74,28 +78,66 @@ async def admin_get(request: Request): conn.close() -@router.post("/admin/activate", response_class=HTMLResponse) -async def admin_activate( - request: Request, - account_id: int = Form(...), - csrf_token: str = Form(default=""), -): - """Activeaza un cont. PRG: redirect 303 la /admin dupa succes.""" +def _apply_lifecycle(conn, ids: list[int], action: str) -> None: + """Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate + (id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul). + `action`: activate | block | archive | delete.""" + for aid in ids: + try: + if action == "activate": + set_status(conn, aid, "active") + elif action == "block": + set_status(conn, aid, "blocked") + elif action == "archive": + set_status(conn, aid, "archived") + elif action == "delete": + delete_account(conn, aid) + except ValueError: + continue # cont de sistem / inexistent -> sarit + + +def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str): + """Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG. + Evita 4 handlere copy-paste care difera doar prin verb.""" require_admin(request) verify_csrf(request, csrf_token) - conn = get_connection() try: - try: - set_active(conn, account_id, True) - except ValueError as exc: - return _render_admin(request, conn, error=str(exc), status_code=422) + _apply_lifecycle(conn, account_id, action) + conn.commit() finally: conn.close() - return RedirectResponse("/admin", status_code=303) +@router.post("/admin/activate", response_class=HTMLResponse) +async def admin_activate(request: Request, account_id: list[int] = Form(...), + csrf_token: str = Form(default="")): + """Activeaza unul sau mai multe conturi (bulk). PRG 303.""" + return _lifecycle_route(request, account_id, csrf_token, "activate") + + +@router.post("/admin/block", response_class=HTMLResponse) +async def admin_block(request: Request, account_id: list[int] = Form(...), + csrf_token: str = Form(default="")): + """Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303.""" + return _lifecycle_route(request, account_id, csrf_token, "block") + + +@router.post("/admin/archive", response_class=HTMLResponse) +async def admin_archive(request: Request, account_id: list[int] = Form(...), + csrf_token: str = Form(default="")): + """Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303.""" + return _lifecycle_route(request, account_id, csrf_token, "archive") + + +@router.post("/admin/delete", response_class=HTMLResponse) +async def admin_delete(request: Request, account_id: list[int] = Form(...), + csrf_token: str = Form(default="")): + """Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303.""" + return _lifecycle_route(request, account_id, csrf_token, "delete") + + @router.post("/admin/deactivate", response_class=HTMLResponse) async def admin_deactivate( request: Request, diff --git a/app/web/routes.py b/app/web/routes.py index 0750061..9d31dad 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -331,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse: "active_tab": active_tab, "panel_html": panel_html, "badges": badges, + "is_authenticated": True, "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index 2c9b700..17130a4 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -44,15 +44,8 @@ {% endif %} - {# === Subordonat: ajutor rapid pe un rand discret === - US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos - pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #} -
+ {# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea + traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #} {# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003). Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul, diff --git a/app/web/templates/_macros.html b/app/web/templates/_macros.html index dca404f..14d9ea5 100644 --- a/app/web/templates/_macros.html +++ b/app/web/templates/_macros.html @@ -1,26 +1,28 @@ {# Macro-uri partajate intre template-urile de import si mapari. #} -{# US-007: comutator pe COADA in loc de bifa "auto-send". - Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele - poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu - semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca - bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get()) - la /_import/.../mapare-operatie). Zero atingere backend. +{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara + proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari). + Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de + tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport). + + INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"` + si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False). + E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())` + la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent + pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual + Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend. - form_id: leaga input-ul de un