5.12 (livrat): editare in modal a randurilor de preview, cont obligatoriu inainte de import, formular editare extras (_form_editare, _editare_preview_modal), plus suita de teste aferenta (preview edit/compact, mapare op, form editare, signup, admin panel). Design + planificare: - docs/design.md: sistem de design (tokeni, breakpoints, scara control, componente, a11y). - docs/prd/prd-5.12-* si prd-5.13-* (5.13 cu raport /autoplan: CEO+Design+Eng, audit trail). Curatare: sterse PNG-urile de test/mockup temporare din radacina. Nota: implementarea CSS 5.13 (responsive compact + sistem butoane) NU e inca facuta — planul revizuit cere refactorul testelor fragile din test_web_responsive.py INAINTE de CSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.8 KiB
Python
161 lines
6.8 KiB
Python
"""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 -> 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,
|
|
) -> 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.
|
|
"""
|
|
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)
|
|
try:
|
|
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
|
cur = conn.execute(
|
|
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)",
|
|
(name, cui, email, 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 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")
|
|
# 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, email, active, status, created_at FROM accounts "
|
|
"WHERE status != 'deleted' ORDER BY id"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|