"""Lifecycle conturi ROAAUTO (admin, fara suprafata HTTP). Functii pure de creare/listare/(de)activare cont, partajate intre CLI (`tools/account.py`, Etapa 3.1) si fluxul web de self-onboarding (Etapa 3.3, care reuseaza `create_account` + `active`). Identitatea de login (email/parola) NU traieste aici — apartine 3.3. NOTA lifecycle `active`: coloana `accounts.active` este un flag de lifecycle consumat de 3.3 (gate „cont in asteptare", `active=0`). Pana la gate-ul worker din 3.3, `active=0` NU opreste trimiterile (worker-ul nu citeste contul, doar `api_keys.active`). `deactivate` marcheaza intentia administrativa; nu blocheaza inca fluxul de trimitere. (Addendum A2.) """ from __future__ import annotations import sqlite3 def _norm_cui(cui: str | None) -> str | None: """trim + upper; sir gol -> None (tratat ca „fara CUI").""" if cui is None: return None cui = cui.strip().upper() return cui or None def create_account( conn: sqlite3.Connection, name: str, cui: str | None = None, active: bool = True ) -> int: """Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1). `name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial `ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta. """ name = (name or "").strip() if not name: 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, 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() owner = existing["id"] if existing else "?" raise ValueError( f"CUI {cui} e deja folosit de contul {owner} " f"(foloseste 'activate --account {owner}' sau alt CUI)" ) return int(cur.lastrowid or 0) 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. 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=?, 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. Exclude conturile 'deleted' (stergere soft -> invizibile in panou).""" rows = conn.execute( "SELECT id, name, cui, active, status, created_at FROM accounts " "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows]