"""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 from datetime import datetime, timedelta, timezone def _norm_cui(cui: str | None) -> str | None: """trim + upper; sir gol -> ValueError daca e string gol, None daca e None.""" if cui is None: return None cui = cui.strip().upper() if cui == "": raise ValueError("CUI gol (un CUI trebuie sa fie un sir nevid)") return cui def _norm_email(email: str | None) -> str | None: """trim + lower; sir gol -> ValueError daca e string gol, None daca e None.""" if email is None: return None email = email.strip().lower() if email == "": raise ValueError("email gol (un email trebuie sa fie un sir nevid)") return email def create_account( conn: sqlite3.Connection, name: str, cui: str | None = None, email: str | None = None, active: bool = True, requested_plan: str | None = None, consent_at: str | None = None, ) -> 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); sir gol -> ValueError. `email` se normalizeaza (trim+lower); sir gol -> ValueError. 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. `requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier` ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor. Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca. `consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant. """ name = (name or "").strip() if not name: raise ValueError("name gol (un cont are nevoie de nume)") cui = _norm_cui(cui) email = _norm_email(email) # Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv). req_plan = requested_plan if requested_plan in VALID_TIERS else None try: # Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z. trial_until = ( (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") ) # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. cur = conn.execute( "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, " "requested_plan, consent_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (name, cui, email, 1 if active else 0, "active" if active else "pending", "free", trial_until, req_plan, consent_at), ) 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 account_is_complete(row: sqlite3.Row | dict) -> bool: """Returneaza True daca contul are companie (name), email si CUI ne-goale. Contul de sistem id=1 (default) este EXCEPTAT si returneaza intotdeauna True (nu are sens sa-l marcam ca incomplet — nu e un cont de client). """ acct_id = row["id"] if "id" in row.keys() else None if acct_id == 1: return True name = (row["name"] or "").strip() cui = (row["cui"] or "").strip() email_val = (row["email"] or "").strip() if "email" in row.keys() else "" return bool(name and cui and email_val) 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") # Tieruri de cont valide (5.17). Sursa de adevar: app/plans.py#PLANS (nu duplica valorile). VALID_TIERS = ("free", "standard", "pro", "premium") # 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 set_tier( conn: sqlite3.Connection, account_id: int, tier: str, trial_until: str | None = None, ) -> None: """Seteaza planul unui cont (tier + trial_until). tier invalid -> ValueError cu mesaj clar. Contul de sistem id=1 e protejat (ca set_status). Cont inexistent -> ValueError. Logheaza schimbarea in app_events (reuse observ.log_event, fara PII nou). trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul). """ if tier not in VALID_TIERS: raise ValueError( f"tier invalid: {tier!r} (valid: {', '.join(VALID_TIERS)})" ) 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: raise ValueError( "Contul default (id=1) nu poate fi mutat pe alt plan via CLI " "(cont de sistem, tratat coerent)." ) conn.execute( "UPDATE accounts SET tier=?, trial_until=? WHERE id=?", (tier, trial_until, account_id), ) # Audit in app_events (decizie PRD 5.17 US-008, fara PII nou) try: from .observ import log_event log_event( "plan_schimbare_tier", account_id=account_id, mesaj=f"tier -> {tier}", context={"tier": tier, "trial_until": trial_until}, conn=conn, ) except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event) pass def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None: """Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`. Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa acorzi un trial fara a schimba tier-ul de baza (post-trial). Contul de sistem id=1 e protejat. Cont inexistent -> ValueError. trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul). """ 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: raise ValueError("Contul default (id=1) nu poate primi trial (cont de sistem).") conn.execute( "UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id) ) # Audit in app_events (best-effort, fara PII nou — ca set_tier). try: from .observ import log_event log_event( "plan_trial_setat", account_id=account_id, mesaj=f"trial_until -> {trial_until or 'NULL'}", context={"trial_until": trial_until}, conn=conn, ) except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event) pass 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, email, active, status, tier, trial_until, " "requested_plan, consent_at, created_at FROM accounts " "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows]