Files
rar-autopass/app/web/admin_routes.py
Claude Agent 851f76ca16 feat(signup+admin): aliniere formular signup la landing + plan cerut, GDPR, control tier/trial in panou
Signup:
- /signup aliniat ca format la formularul din landing (campuri, etichete,
  placeholder-uri, select plan, checkbox GDPR, buton). Eticheta `name` = "Companie"
  (corecta: backendul salveaza nume de firma), uniform si in landing.
- Consimtamant GDPR validat server-side (functional, nu doar client-side) + salvat
  cu marca temporala (accounts.consent_at).
- Plan ales la signup salvat in accounts.requested_plan (intentie, NU drept): tier
  ramane sursa de adevar pentru gate-ul API; coloana pregateste integrarea platilor.
- landing: valorile `plan` = coduri tier (free/standard/pro/premium), data-plan
  sincronizat pe butoanele de pret; checkbox consimtamant primeste name.

Schema/DB:
- accounts: coloane noi requested_plan + consent_at (cu migrare aditiva in db.py).

Panou admin:
- Coloane noi: Plan curent (plan EFECTIV acum + zile trial ramase) si Plan cerut.
- Buton "Aplica" (POST /admin/set-tier): aloca plan real si INCHEIE trial-ul
  (efect imediat; altfel trial-ul Pro universal de 30z masca alegerea).
- Control "Trial Pro N zile" (POST /admin/set-trial via accounts.set_trial):
  acorda/prelungeste trial fara a schimba tier-ul de baza.

Teste: signup (consent obligatoriu, requested_plan persistat, tier ramane free),
panou admin (set-tier incheie trial, free opreste Pro imediat, set-trial, validari
+ CSRF). Call-site-urile existente POST /signup actualizate cu consent.

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

287 lines
11 KiB
Python

"""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)