Files
rar-autopass/app/accounts.py
Claude Agent c9f9a1ca0e feat(5.16+5.17): tipografie/antet branded + tipuri cont, planuri si enforcement
PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat):
- US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero
  @font-face si zero /static/fonts/; landing aliniat la acelasi stack
- US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat
  (invariant zero-silent-failures pastrat)
- US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan;
  meniu burger cu separatoare; gate strict pe is_authenticated
- US-011: selector tema pill icon+eticheta (reuse THEMES)
- US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod
  operatii, cod ales se salveaza fara "+", Renunta inchide via closest)
- US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni
- fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock

PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR:
- US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py
  sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage,
  CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit)
- US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale
  (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil);
  valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch)
- US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO
  pluralizat + banner one-time trial->Gratuit + pagina Cont

Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat.
Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18
(corpus kNN) ramane separat, necomis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:02:40 +00:00

215 lines
8.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
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,
) -> 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:
# 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) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(name, cui, email, 1 if active else 0, "active" if active else "pending",
"free", trial_until),
)
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 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, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id"
).fetchall()
return [dict(r) for r in rows]