"""Panou admin web /admin. Rute: GET /admin — listeaza conturi in asteptare + active (require_admin) POST /admin/activate — activeaza un cont (require_admin + CSRF, PRG) POST /admin/deactivate — dezactiveaza un cont, nu permite id=1 (require_admin + CSRF, PRG) """ from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import __version__ from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account from ..config import get_settings from ..db import get_connection from ..plans import PLANS, effective_tier from ..web.csrf import get_csrf_token, verify_csrf from ..web.session import require_admin def _plan_label(code: str | None) -> str: """Eticheta RO a unui cod de plan (din PLANS). None/necunoscut -> '—'.""" if not code: return "—" plan = PLANS.get(code) return plan["label"] if plan else code def _trial_zile_ramase(trial_until_str: str | None, now: datetime) -> int | None: """Zile ramase din trial (rotunjit in sus), sau None daca nu e trial activ/malformat. Acelasi parsing tolerant ca plans.effective_tier (UTC implicit pe valori naive). """ if not trial_until_str: return None try: tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) if tu.tzinfo is None: tu = tu.replace(tzinfo=timezone.utc) now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc) secunde = (tu - now_cmp).total_seconds() if secunde <= 0: return None # Rotunjire in sus la zile (o fractie de zi ramasa = inca 1 zi afisata). return int(secunde // 86400) + (1 if secunde % 86400 else 0) except (ValueError, AttributeError, TypeError): return None router = APIRouter() _TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) def _ctx(request: Request, **extra) -> dict: settings = get_settings() return {"rar_env": settings.rar_env, "version": __version__, **extra} def _emails_by_account(conn) -> dict[int, str | None]: """Intoarce primul email per account_id, intr-un singur query (fara N+1).""" rows = conn.execute( "SELECT account_id, email FROM users ORDER BY id" ).fetchall() result: dict[int, str | None] = {} for row in rows: acc_id = int(row["account_id"]) if acc_id not in result: result[acc_id] = row["email"] return result def _render_admin(request: Request, conn, *, error: str | None = None, status_code: int = 200): """Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare.""" accounts = list_accounts(conn) emails = _emails_by_account(conn) now = datetime.now(timezone.utc) for acct in accounts: # Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului acct["is_complete"] = account_is_complete(acct) acct["email"] = emails.get(acct["id"]) # Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`. # `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup. eff = effective_tier(acct, now) acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial) acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM acct["trial_activ"] = eff != (acct.get("tier") or "free") acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now) acct["requested_plan_label"] = _plan_label(acct.get("requested_plan")) # Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0) # ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts. pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1] active = [a for a in accounts if a["status"] == "active" and a["id"] != 1] suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1] return _TMPL.TemplateResponse(request, "admin.html", _ctx( request, csrf_token=get_csrf_token(request), pending=pending, active=active, suspended=suspended, error=error, is_authenticated=True, is_admin=True, ), status_code=status_code) @router.get("/admin", response_class=HTMLResponse) async def admin_get(request: Request): """Panou admin: conturi in asteptare + active.""" require_admin(request) conn = get_connection() try: return _render_admin(request, conn) finally: conn.close() def _apply_lifecycle(conn, ids: list[int], action: str) -> None: """Aplica un verb de ciclu de viata pe o lista de conturi. Conturile protejate (id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul). `action`: activate | block | archive | delete.""" for aid in ids: try: if action == "activate": # Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI) acct_row = conn.execute( "SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,) ).fetchone() if acct_row and not account_is_complete(acct_row): continue # sarim activarea — contul incomplet ramane pending set_status(conn, aid, "active") elif action == "block": set_status(conn, aid, "blocked") elif action == "archive": set_status(conn, aid, "archived") elif action == "delete": delete_account(conn, aid) except ValueError: continue # cont de sistem / inexistent -> sarit def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str): """Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG. Evita 4 handlere copy-paste care difera doar prin verb.""" require_admin(request) verify_csrf(request, csrf_token) conn = get_connection() try: _apply_lifecycle(conn, account_id, action) conn.commit() finally: conn.close() return RedirectResponse("/admin", status_code=303) @router.post("/admin/activate", response_class=HTMLResponse) async def admin_activate(request: Request, account_id: list[int] = Form(...), csrf_token: str = Form(default="")): """Activeaza unul sau mai multe conturi (bulk). PRG 303.""" return _lifecycle_route(request, account_id, csrf_token, "activate") @router.post("/admin/block", response_class=HTMLResponse) async def admin_block(request: Request, account_id: list[int] = Form(...), csrf_token: str = Form(default="")): """Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303.""" return _lifecycle_route(request, account_id, csrf_token, "block") @router.post("/admin/archive", response_class=HTMLResponse) async def admin_archive(request: Request, account_id: list[int] = Form(...), csrf_token: str = Form(default="")): """Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303.""" return _lifecycle_route(request, account_id, csrf_token, "archive") @router.post("/admin/delete", response_class=HTMLResponse) async def admin_delete(request: Request, account_id: list[int] = Form(...), csrf_token: str = Form(default="")): """Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303.""" return _lifecycle_route(request, account_id, csrf_token, "delete") @router.post("/admin/set-tier", response_class=HTMLResponse) async def admin_set_tier( request: Request, account_id: int = Form(...), tier: str = Form(...), csrf_token: str = Form(default=""), ): """Schimba planul (tier) unui cont din panou. require_admin + CSRF, PRG 303. Reuseaza accounts.set_tier (valideaza tier-ul, protejeaza id=1, logheaza schimbarea). INCHEIE trial-ul (trial_until=NULL): alocarea manuala = plan real de-acum, cu efect imediat — altfel trial-ul Pro universal (30z la signup) ar masca alegerea pana la expirare (decizie user 2026-06-29). Tier invalid / cont protejat -> re-randare cu eroare. """ require_admin(request) verify_csrf(request, csrf_token) conn = get_connection() try: try: # trial_until=None: alocarea manuala incheie trial-ul si aplica tier-ul ales acum. set_tier(conn, account_id, tier, trial_until=None) conn.commit() except ValueError as exc: return _render_admin(request, conn, error=str(exc), status_code=422) finally: conn.close() return RedirectResponse("/admin", status_code=303) @router.post("/admin/set-trial", response_class=HTMLResponse) async def admin_set_trial( request: Request, account_id: int = Form(...), trial_days: int = Form(...), csrf_token: str = Form(default=""), ): """Acorda/prelungeste un trial Pro de N zile (de la acum), fara a schimba tier-ul de baza. require_admin + CSRF, PRG 303. Reuseaza accounts.set_trial (protejeaza id=1, logheaza). trial_days <= 0 sau peste plafon -> re-randare panou cu eroare (422). Plafon defensiv 3650z. """ require_admin(request) verify_csrf(request, csrf_token) conn = get_connection() try: if trial_days <= 0 or trial_days > 3650: return _render_admin( request, conn, error="Numarul de zile pentru trial trebuie sa fie intre 1 si 3650.", status_code=422, ) try: now = datetime.now(timezone.utc) trial_until = (now + timedelta(days=trial_days)).strftime("%Y-%m-%d %H:%M:%S") set_trial(conn, account_id, trial_until) conn.commit() except ValueError as exc: return _render_admin(request, conn, error=str(exc), status_code=422) finally: conn.close() return RedirectResponse("/admin", status_code=303) @router.post("/admin/deactivate", response_class=HTMLResponse) async def admin_deactivate( request: Request, account_id: int = Form(...), csrf_token: str = Form(default=""), ): """Dezactiveaza un cont. Nu permite dezactivarea contului default id=1. PRG: redirect 303.""" require_admin(request) verify_csrf(request, csrf_token) if account_id == 1: conn = get_connection() try: return _render_admin( request, conn, error="Contul default (id=1) nu poate fi dezactivat.", status_code=422, ) finally: conn.close() conn = get_connection() try: try: set_active(conn, account_id, False) except ValueError as exc: return _render_admin(request, conn, error=str(exc), status_code=422) finally: conn.close() return RedirectResponse("/admin", status_code=303)