feat(account): CLI lifecycle conturi + accounts.active (PRD 3.1)

Inlocuieste crearea conturilor prin INSERT SQL manual cu un tool admin
dedicat, simetric cu tools/apikey.py. Fundatia Etapei 3 (3.2/3.3).

- app/accounts.py: create_account/set_active/list_accounts (helper pur,
  partajat CLI + viitor flux web 3.3). Normalizeaza CUI (trim+upper),
  prinde IntegrityError -> ValueError cu cauza+fix.
- accounts.active (lifecycle cont) + index unic partial ux_accounts_cui
  (unicitate la nivel de index, fara fereastra de coliziune). Migrare
  idempotenta in _migrate.
- tools/account.py: create (--name/--cui/--inactive/--with-key atomic),
  list [--pending], activate/deactivate --account N. Erori -> exit 2.
- 20 teste noi (12 helper + 8 CLI); suita 299 passed.

active e inert pana la gate-ul worker din 3.3 (documentat).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-17 12:38:13 +00:00
parent 6515de415b
commit 1c5b0cbc18
8 changed files with 475 additions and 5 deletions

70
app/accounts.py Normal file
View File

@@ -0,0 +1,70 @@
"""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:
cur = conn.execute(
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)",
(name, cui, 1 if active else 0),
)
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."""
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))
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id."""
rows = conn.execute(
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id"
).fetchall()
return [dict(r) for r in rows]

View File

@@ -60,6 +60,13 @@ def _migrate(conn: sqlite3.Connection) -> None:
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
if "rar_creds_enc" not in acc_cols:
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
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")
# 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"
)
# Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute(

View File

@@ -10,9 +10,14 @@ CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cui TEXT,
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
-- fara CUI (ex. default) se pot crea multiplu. Unicitate la nivel de index (nu check
-- in helper) ca sa nu existe fereastra de coliziune intre doi create_account concurenti.
CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL;
-- Cont implicit (id=1): auth API-key (CORE) inca neimplementat, deci ingestiile vin
-- cu account_id NULL. Le atribuim contului default ca FK + UNIQUE(account_id,...) din
-- operations_mapping sa fie valide; cand auth livreaza, account_id real va curge natural.