Compare commits
3 Commits
8f39dfbc1e
...
851f76ca16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851f76ca16 | ||
|
|
a29896a790 | ||
|
|
3f513f6c12 |
@@ -44,6 +44,8 @@ def create_account(
|
|||||||
cui: str | None = None,
|
cui: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
active: bool = True,
|
active: bool = True,
|
||||||
|
requested_plan: str | None = None,
|
||||||
|
consent_at: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
||||||
|
|
||||||
@@ -51,12 +53,19 @@ def create_account(
|
|||||||
`email` se normalizeaza (trim+lower); 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
|
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.
|
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
||||||
|
|
||||||
|
`requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier`
|
||||||
|
ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor.
|
||||||
|
Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca.
|
||||||
|
`consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant.
|
||||||
"""
|
"""
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||||
cui = _norm_cui(cui)
|
cui = _norm_cui(cui)
|
||||||
email = _norm_email(email)
|
email = _norm_email(email)
|
||||||
|
# Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv).
|
||||||
|
req_plan = requested_plan if requested_plan in VALID_TIERS else None
|
||||||
try:
|
try:
|
||||||
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
# Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z.
|
||||||
trial_until = (
|
trial_until = (
|
||||||
@@ -64,10 +73,11 @@ def create_account(
|
|||||||
)
|
)
|
||||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until) "
|
"INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"requested_plan, consent_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
(name, cui, email, 1 if active else 0, "active" if active else "pending",
|
||||||
"free", trial_until),
|
"free", trial_until, req_plan, consent_at),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||||
@@ -185,6 +195,38 @@ def set_tier(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None:
|
||||||
|
"""Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`.
|
||||||
|
|
||||||
|
Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi
|
||||||
|
plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa
|
||||||
|
acorzi un trial fara a schimba tier-ul de baza (post-trial).
|
||||||
|
|
||||||
|
Contul de sistem id=1 e protejat. Cont inexistent -> ValueError.
|
||||||
|
trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul).
|
||||||
|
"""
|
||||||
|
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 primi trial (cont de sistem).")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id)
|
||||||
|
)
|
||||||
|
# Audit in app_events (best-effort, fara PII nou — ca set_tier).
|
||||||
|
try:
|
||||||
|
from .observ import log_event
|
||||||
|
log_event(
|
||||||
|
"plan_trial_setat",
|
||||||
|
account_id=account_id,
|
||||||
|
mesaj=f"trial_until -> {trial_until or 'NULL'}",
|
||||||
|
context={"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:
|
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
||||||
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
"""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
|
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
||||||
@@ -208,7 +250,8 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]:
|
|||||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||||
(stergere soft -> invizibile in panou)."""
|
(stergere soft -> invizibile in panou)."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, cui, email, active, status, tier, trial_until, created_at FROM accounts "
|
"SELECT id, name, cui, email, active, status, tier, trial_until, "
|
||||||
|
"requested_plan, consent_at, created_at FROM accounts "
|
||||||
"WHERE status != 'deleted' ORDER BY id"
|
"WHERE status != 'deleted' ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if "trial_until" not in acc_cols:
|
if "trial_until" not in acc_cols:
|
||||||
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
|
# Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial).
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT")
|
||||||
|
if "requested_plan" not in acc_cols:
|
||||||
|
# Planul cerut la signup (integrare plati). NU acorda drepturi; `tier` ramane sursa
|
||||||
|
# de adevar pt API/volum. Nullable. ALTER nu poate adauga CHECK pe coloana noua in
|
||||||
|
# SQLite -> validarea valorilor se face in cod (signup, fata de VALID_TIERS).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN requested_plan TEXT")
|
||||||
|
if "consent_at" not in acc_cols:
|
||||||
|
# Marca temporala consimtamant Termeni+GDPR (proba). Nullable (NULL = CLI/legacy).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN consent_at TEXT")
|
||||||
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
tier TEXT NOT NULL DEFAULT 'free'
|
tier TEXT NOT NULL DEFAULT 'free'
|
||||||
CHECK (tier IN ('free','standard','pro','premium')),
|
CHECK (tier IN ('free','standard','pro','premium')),
|
||||||
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
|
trial_until TEXT, -- ISO datetime UTC sau NULL; nullable
|
||||||
|
-- Planul CERUT de client la signup (separat de `tier`). NU acorda drepturi:
|
||||||
|
-- `tier` ramane sursa unica de adevar pentru gate-ul API (require_api_access) si volum.
|
||||||
|
-- Folosit la integrarea platilor: client cere plan -> plateste -> admin/webhook urca `tier`
|
||||||
|
-- -> API se deblocheaza. NULL = necunoscut (cont creat via CLI / inainte de coloana).
|
||||||
|
requested_plan TEXT
|
||||||
|
CHECK (requested_plan IS NULL OR requested_plan IN ('free','standard','pro','premium')),
|
||||||
|
-- Marca temporala a acceptarii Termenilor + politicii de confidentialitate (GDPR, L.142).
|
||||||
|
-- Setata la signup (proba de consimtamant). NULL = cont fara flux de consimtamant (CLI/legacy).
|
||||||
|
consent_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Rute:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
@@ -15,12 +16,42 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account
|
from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
|
from ..plans import PLANS, effective_tier
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from ..web.session import require_admin
|
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()
|
router = APIRouter()
|
||||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
|
|
||||||
@@ -47,10 +78,19 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
|||||||
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
||||||
accounts = list_accounts(conn)
|
accounts = list_accounts(conn)
|
||||||
emails = _emails_by_account(conn)
|
emails = _emails_by_account(conn)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
|
||||||
acct["is_complete"] = account_is_complete(acct)
|
acct["is_complete"] = account_is_complete(acct)
|
||||||
acct["email"] = emails.get(acct["id"])
|
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)
|
# 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.
|
# 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]
|
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||||
@@ -146,6 +186,73 @@ async def admin_delete(request: Request, account_id: list[int] = Form(...),
|
|||||||
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
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)
|
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||||
async def admin_deactivate(
|
async def admin_deactivate(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
@@ -9,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..accounts import create_account
|
from ..accounts import VALID_TIERS, create_account
|
||||||
from ..auth import create_api_key
|
from ..auth import create_api_key
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
@@ -47,10 +48,18 @@ async def signup_post(
|
|||||||
cui: str = Form(default=""),
|
cui: str = Form(default=""),
|
||||||
email: str = Form(default=""),
|
email: str = Form(default=""),
|
||||||
parola: str = Form(default=""),
|
parola: str = Form(default=""),
|
||||||
|
plan: str = Form(default=""),
|
||||||
|
consent: str = Form(default=""),
|
||||||
csrf_token: str = Form(default=""),
|
csrf_token: str = Form(default=""),
|
||||||
):
|
):
|
||||||
verify_csrf(request, csrf_token)
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
# Planul CERUT (intentie, nu drept): pastram doar valori valide; orice altceva -> 'free'.
|
||||||
|
# `tier`-ul real ramane 'free' la creare; planul ales se onoreaza dupa plata (admin/webhook).
|
||||||
|
requested_plan = plan.strip().lower() if plan else ""
|
||||||
|
if requested_plan not in VALID_TIERS:
|
||||||
|
requested_plan = "free"
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||||
@@ -58,7 +67,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=_RATE_MSG,
|
error=_RATE_MSG,
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=429)
|
), status_code=429)
|
||||||
|
|
||||||
if len(parola) < _PASSWORD_MIN:
|
if len(parola) < _PASSWORD_MIN:
|
||||||
@@ -66,7 +75,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
|
|
||||||
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
# CUI obligatoriu la signup (US-001, PRD 5.12)
|
||||||
@@ -76,9 +85,19 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error="CUI-ul firmei este obligatoriu.",
|
error="CUI-ul firmei este obligatoriu.",
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
|
|
||||||
|
# Consimtamant Termeni + GDPR obligatoriu (proba). Checkbox bifat -> valoare ne-goala.
|
||||||
|
if not (consent and consent.strip()):
|
||||||
|
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||||
|
request,
|
||||||
|
csrf_token=get_csrf_token(request),
|
||||||
|
error="Trebuie sa accepti Termenii si prelucrarea datelor (GDPR) pentru a crea cont.",
|
||||||
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
|
), status_code=422)
|
||||||
|
consent_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
||||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -86,7 +105,10 @@ async def signup_post(
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
is_first = count_admins(conn) == 0
|
is_first = count_admins(conn) == 0
|
||||||
account_id = create_account(conn, name, cui=cui_norm, email=email, active=False)
|
account_id = create_account(
|
||||||
|
conn, name, cui=cui_norm, email=email, active=False,
|
||||||
|
requested_plan=requested_plan, consent_at=consent_at,
|
||||||
|
)
|
||||||
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||||
api_key = create_api_key(conn, account_id)
|
api_key = create_api_key(conn, account_id)
|
||||||
conn.execute("COMMIT")
|
conn.execute("COMMIT")
|
||||||
@@ -121,7 +143,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=error_msg,
|
error=error_msg,
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
conn.execute("ROLLBACK")
|
conn.execute("ROLLBACK")
|
||||||
@@ -129,7 +151,7 @@ async def signup_post(
|
|||||||
request,
|
request,
|
||||||
csrf_token=get_csrf_token(request),
|
csrf_token=get_csrf_token(request),
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email, plan=requested_plan,
|
||||||
), status_code=422)
|
), status_code=422)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
'delete': ('Sterge', '/admin/delete', 'danger')
|
'delete': ('Sterge', '/admin/delete', 'danger')
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
|
{# Tier-uri selectabile in panou (cod, eticheta). Aliniat cu app/plans.py#PLANS. #}
|
||||||
|
{% set TIERS = [('free', 'Gratuit'), ('standard', 'Standard'), ('pro', 'Pro'), ('premium', 'Premium')] %}
|
||||||
|
|
||||||
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
||||||
aria-label="Selecteaza tot"></th>
|
aria-label="Selecteaza tot"></th>
|
||||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Plan curent</th><th>Plan cerut</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for acct in rows %}
|
{% for acct in rows %}
|
||||||
@@ -46,6 +49,45 @@
|
|||||||
<td>{{ acct.name }}</td>
|
<td>{{ acct.name }}</td>
|
||||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||||
<td>{{ acct.email or "—" }}</td>
|
<td>{{ acct.email or "—" }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
{# Plan EFECTIV acum (prominent): trial Pro activ ridica free->pro. #}
|
||||||
|
<div style="margin-bottom:5px;">
|
||||||
|
<span class="pill" style="font-weight:600;">{{ acct.tier_efectiv_label }}</span>
|
||||||
|
{% if acct.trial_activ %}
|
||||||
|
<span class="muted" style="font-size:11px;">
|
||||||
|
trial{% if acct.trial_zile %} · {{ acct.trial_zile }} {{ 'zi' if acct.trial_zile == 1 else 'zile' }} ramase{% endif %}
|
||||||
|
→ apoi {{ acct.tier_label }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{# Schimbare plan inline: select tier de baza + Aplica. Form propriu (nu imbricat in bulk-form).
|
||||||
|
Aplica INCHEIE trial-ul si seteaza planul ales ca real, cu efect imediat. #}
|
||||||
|
<form method="post" action="/admin/set-tier" class="tier-form"
|
||||||
|
style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||||
|
<select name="tier" aria-label="Plan pentru {{ acct.name }}"
|
||||||
|
style="padding:4px 8px;min-height:32px;max-width:130px;">
|
||||||
|
{% for code, label in TIERS %}
|
||||||
|
<option value="{{ code }}"{% if acct.tier == code %} selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-sm"
|
||||||
|
title="Aplica planul ales ca plan real (incheie trial-ul daca e activ)">Aplica</button>
|
||||||
|
</form>
|
||||||
|
{# Acorda/prelungeste trial Pro de N zile, fara a schimba tier-ul de baza. #}
|
||||||
|
<form method="post" action="/admin/set-trial" class="trial-form"
|
||||||
|
style="display:flex;align-items:center;gap:6px;margin-top:5px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||||
|
<input type="number" name="trial_days" value="30" min="1" max="3650"
|
||||||
|
aria-label="Zile trial Pro pentru {{ acct.name }}"
|
||||||
|
style="padding:4px 8px;min-height:32px;width:64px;">
|
||||||
|
<button type="submit" class="btn-sm"
|
||||||
|
title="Acorda/prelungeste trial Pro de la acum (nu schimba tier-ul de baza)">Trial Pro</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ acct.requested_plan_label }}</td>
|
||||||
<td><span class="pill">{{ acct.status }}</span></td>
|
<td><span class="pill">{{ acct.status }}</span></td>
|
||||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
|
|||||||
@@ -54,18 +54,12 @@
|
|||||||
<body data-theme="grafit">
|
<body data-theme="grafit">
|
||||||
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
<script>try{var _t=localStorage.getItem('lp-theme');if(_t&&['grafit','cobalt','cupru','hartie'].indexOf(_t)>=0)document.body.setAttribute('data-theme',_t);}catch(e){}</script>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<!-- ANNOUNCE BAR -->
|
<!-- HEADER -->
|
||||||
<div style="display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 40px;background:var(--card,#181c24);border-bottom:1px solid var(--line,#262b36);font:500 13px var(--font-ui);color:var(--text,#e6e9ef);flex-wrap:wrap;">
|
|
||||||
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Vrei să testezi sau ai un service mic? Este <strong style="font-weight:700;color:#1F9D5C;">gratuit</strong> — până la 60 de prestații/lună, fără card bancar.</span>
|
|
||||||
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;transition:color .18s ease, transform .18s ease;" style-hover="color:#17a96e;transform:translateX(2px)">Creează cont în 2 minute <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- HEADER -->
|
|
||||||
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
||||||
<div style="display:flex;align-items:center;gap:48px;">
|
<div style="display:flex;align-items:center;gap:48px;">
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
||||||
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
<span>Cum funcționează</span><span>API</span><span>Preț</span>
|
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
||||||
@@ -81,9 +75,9 @@
|
|||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px var(--font-ui);margin-bottom:24px;">
|
<div style="display:inline-flex;align-items:center;gap:12px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px var(--font-ui);margin-bottom:24px;flex-wrap:wrap;">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>
|
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Este gratuit pentru service-urile mici — până la 60 de trimiteri RAR/lună</span>
|
||||||
<span><strong style="font-weight:700;color:#1F9D5C;">Gratuit</strong> pentru testare și service-uri mici · 60 prestații/lună</span>
|
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;" >Creează cont în 2 minute <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
||||||
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
|
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
|
||||||
@@ -205,7 +199,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOLVE -->
|
<!-- SOLVE -->
|
||||||
<div style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
<div id="cum-functioneaza" style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
||||||
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
||||||
@@ -216,7 +210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API INTEGRATION -->
|
<!-- API INTEGRATION -->
|
||||||
<div style="padding:0 40px 80px;">
|
<div id="api" style="padding:0 40px 80px;">
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div>
|
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div>
|
||||||
@@ -243,7 +237,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PRICING -->
|
<!-- PRICING -->
|
||||||
<div style="padding:0 40px 80px;">
|
<div id="pret" style="padding:0 40px 80px;">
|
||||||
<div style="text-align:center;margin-bottom:44px;">
|
<div style="text-align:center;margin-bottom:44px;">
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div>
|
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div>
|
||||||
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
||||||
@@ -265,7 +259,7 @@
|
|||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
||||||
<span style="display:none;"></span>
|
<span style="display:none;"></span>
|
||||||
</div>
|
</div>
|
||||||
<button data-act="auth" data-tab="register" data-plan="Gratuit" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Standard -->
|
<!-- Standard -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
||||||
@@ -279,7 +273,7 @@
|
|||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
||||||
<span style="display:none;"></span>
|
<span style="display:none;"></span>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Standard">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pro -->
|
<!-- Pro -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;">
|
||||||
@@ -294,7 +288,7 @@
|
|||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare la operațiile incerte</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare la operațiile incerte</div>
|
||||||
<span style="display:none;"></span>
|
<span style="display:none;"></span>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Pro">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Premium -->
|
<!-- Premium -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
||||||
@@ -307,7 +301,7 @@
|
|||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic și online</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic și online</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Asistență și onboarding dedicate</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Asistență și onboarding dedicate</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="Premium">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,12 +348,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<form method="post" action="/signup" data-pane="register">
|
<form method="post" action="/signup" data-pane="register">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Nume contact</span><input type="text" name="name" required placeholder="Ion Popescu" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Companie</span><input type="text" name="name" required placeholder="SC Service Auto SRL" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="Gratuit" selected>Gratuit — 0 lei/lună</option><option value="Standard">Standard — 39 lei/lună</option><option value="Pro">Pro — 59 lei/lună</option><option value="Premium">Premium — la cerere</option></select></label>
|
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 39 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
||||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
||||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
||||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,31 +8,7 @@
|
|||||||
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
<div class="login-2col" style="max-width:860px; margin:32px auto;">
|
||||||
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
|
||||||
<div class="login-shell">
|
<div class="login-shell">
|
||||||
{# === Coloana stanga: brand + trust === #}
|
{# === Formular autentificare === #}
|
||||||
<aside class="login-aside" aria-label="Despre ROMFAST AUTOPASS">
|
|
||||||
<div class="login-brand-row">
|
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:36px; width:auto;">
|
|
||||||
</div>
|
|
||||||
<h2 class="login-headline">ROMFAST <span style="color:var(--accent);">AUTOPASS</span></h2>
|
|
||||||
<p class="login-tagline">Declara prestatiile de service-auto la RAR AUTOPASS, automat.
|
|
||||||
Conform Legii 142/2023 si OMTI 210/2024.</p>
|
|
||||||
<ul class="login-trust">
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Conform Legii 142/2023 si OMTI 210/2024
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
|
|
||||||
Datele criptate, sterse la 3 luni
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Parte din familia ROA — Romfast Applications
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{# === Coloana dreapta: formular (NESCHIMBAT — CSRF, POST /login, link signup) === #}
|
|
||||||
<div class="login-form-col">
|
<div class="login-form-col">
|
||||||
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
<h3 style="font-size:var(--fs-xl); margin:0 0 4px;">Autentificare</h3>
|
||||||
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
<p style="font-size:var(--fs-sm); color:var(--muted); margin:0 0 22px;">
|
||||||
@@ -66,33 +42,18 @@
|
|||||||
<style>
|
<style>
|
||||||
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
|
||||||
.login-shell {
|
.login-shell {
|
||||||
display:grid; grid-template-columns:1.1fr 0.9fr;
|
display:grid; grid-template-columns:1fr;
|
||||||
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
border:1px solid var(--line); border-radius:16px; overflow:hidden;
|
||||||
background:var(--card); min-height:480px;
|
background:var(--card); max-width:460px; margin:0 auto;
|
||||||
}
|
}
|
||||||
.login-aside {
|
|
||||||
padding:40px 38px;
|
|
||||||
background:linear-gradient(160deg, color-mix(in srgb,var(--accent) 12%,var(--card)), var(--card));
|
|
||||||
border-right:1px solid var(--line);
|
|
||||||
display:flex; flex-direction:column; justify-content:center;
|
|
||||||
}
|
|
||||||
.login-brand-row { display:flex; align-items:center; gap:10px; margin-bottom:24px; }
|
|
||||||
.login-headline { font-size:var(--fs-2xl); line-height:var(--lh-tight); margin:0 0 12px; letter-spacing:-.02em; }
|
|
||||||
.login-tagline { font-size:var(--fs-md); color:var(--muted); line-height:1.6; margin:0 0 20px; max-width:340px; }
|
|
||||||
.login-trust { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px; }
|
|
||||||
.login-trust li { display:flex; align-items:center; gap:9px; font-size:var(--fs-sm); color:var(--ink); }
|
|
||||||
.login-trust svg { flex-shrink:0; color:var(--ok); }
|
|
||||||
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
.login-form-col { padding:40px 38px; display:flex; flex-direction:column; justify-content:center; }
|
||||||
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
.btn-primary-full { width:100%; min-height:46px; font-family:var(--font-ui); font-size:var(--fs-md);
|
||||||
font-weight:600; background:var(--accent); color:#fff; border:none;
|
font-weight:600; background:var(--accent); color:#fff; border:none;
|
||||||
border-radius:8px; cursor:pointer; margin-top:4px; }
|
border-radius:8px; cursor:pointer; margin-top:4px; }
|
||||||
.btn-primary-full:hover { filter:brightness(1.08); }
|
.btn-primary-full:hover { filter:brightness(1.08); }
|
||||||
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
.login-foot { text-align:center; font-size:var(--fs-sm); color:var(--muted); margin-top:18px; }
|
||||||
/* Mobil: stivuire verticala, formular sus */
|
|
||||||
@media (max-width:640px) {
|
@media (max-width:640px) {
|
||||||
.login-shell { grid-template-columns:1fr; grid-template-rows:auto auto; }
|
.login-form-col { padding:28px 22px; }
|
||||||
.login-aside { order:2; border-right:none; border-top:1px solid var(--line); padding:28px 22px; }
|
|
||||||
.login-form-col { order:1; padding:28px 22px; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -37,33 +37,53 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2 style="margin-top:0;">Inregistrare cont nou</h2>
|
<h2 style="margin-top:0;">Creează cont nou</h2>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri,
|
||||||
|
etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium),
|
||||||
|
normalizate server-side. #}
|
||||||
<form method="post" action="/signup">
|
<form method="post" action="/signup">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<p>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<label>Companie <span style="color:var(--err)">*</span></label><br>
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Companie</span>
|
||||||
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
<input type="text" name="name" value="{{ name or '' }}" required placeholder="SC Service Auto SRL"
|
||||||
</p>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
<p>
|
</label>
|
||||||
<label>CUI <span style="color:var(--err)">*</span></label><br>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">CUI</span>
|
||||||
</p>
|
<input type="text" name="cui" value="{{ cui or '' }}" required placeholder="RO12345678"
|
||||||
<p>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-mono);outline:none;">
|
||||||
<label>Email <span style="color:var(--err)">*</span></label><br>
|
</label>
|
||||||
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;">
|
<label style="display:block;margin-bottom:14px;">
|
||||||
</p>
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Email</span>
|
||||||
<p>
|
<input type="email" name="email" value="{{ email or '' }}" required placeholder="nume@service.ro"
|
||||||
<label>Parola <span style="color:var(--err)">*</span>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span>
|
</label>
|
||||||
</label><br>
|
<label style="display:block;margin-bottom:14px;">
|
||||||
<input type="password" name="parola" required style="width:100%;">
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Parolă</span>
|
||||||
</p>
|
<input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere"
|
||||||
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button>
|
style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;">
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:16px;">
|
||||||
|
<span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--muted);">Pachet ales</span>
|
||||||
|
<select name="plan"
|
||||||
|
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
||||||
|
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
||||||
|
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 39 lei/lună</option>
|
||||||
|
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||||
|
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--muted);cursor:pointer;">
|
||||||
|
<input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent);width:16px;height:16px;flex-shrink:0;">
|
||||||
|
Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).
|
||||||
|
</label>
|
||||||
|
<button type="submit"
|
||||||
|
style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;">Creează cont gratuit</button>
|
||||||
</form>
|
</form>
|
||||||
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
<p style="text-align:center;font-size:13px;margin-top:16px;">
|
||||||
Ai deja cont? <a href="/login">Autentificare</a>
|
Ai deja cont? <a href="/login">Autentificare</a>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
495
docs/prd/prd-5.19-auto-send-manual-coada.md
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-150326.md -->
|
||||||
|
# PRD 5.19 — Bifa "Auto": transmitere automata sau manuala din coada
|
||||||
|
|
||||||
|
> Status: DRAFT (asteapta aprobare). Sursa de contract: `docs/api-rar-contract.md`.
|
||||||
|
> Limba: romana, fara emoji. Stil: aditiv, nedistructiv pe backend-ul de trimitere.
|
||||||
|
|
||||||
|
## 1. Introducere
|
||||||
|
|
||||||
|
Astazi transmiterea catre RAR e controlata de un singur comutator **global**
|
||||||
|
(`AUTOPASS_WORKER_SEND_ENABLED`, env): cand e pornit, worker-ul ia ORICE rand `queued`
|
||||||
|
al unui cont `active` si il trimite imediat. Nu exista un control **per-cont** care sa
|
||||||
|
permita unui service sa-si tina prezentarile in coada pentru verificare umana inainte
|
||||||
|
de a pleca la RAR.
|
||||||
|
|
||||||
|
Cazul concret care motiveaza feature-ul: utilizatorul testeaza canalul API din ROAAUTO
|
||||||
|
(Visual FoxPro) direct in **productie** (`autopass.romfast.ro`), pe contul lui de test.
|
||||||
|
Vrea ca prezentarile sa **apara in coada si sa astepte**, nu sa plece automat la RAR,
|
||||||
|
pana cand le verifica si apasa explicit "Trimite". Reper vizual: bifa **"Auto"** din
|
||||||
|
dashboard-ul gomag-vending (`image.png`).
|
||||||
|
|
||||||
|
## 2. Obiective
|
||||||
|
|
||||||
|
### Obiectiv principal
|
||||||
|
- Un comutator **"Auto" per-cont**, persistat pe contul service-ului: cand e bifat,
|
||||||
|
prezentarile pleaca automat la RAR (comportament actual); cand e debifat, randurile
|
||||||
|
**asteapta vizibil in coada** pana cand un operator le trimite manual.
|
||||||
|
|
||||||
|
### Obiective secundare
|
||||||
|
- Trimitere manuala **per rand** ("Trimite") si in **bloc** ("Trimite toate (N)",
|
||||||
|
analogul "Start Sync" din gomag).
|
||||||
|
- La activarea Auto (OFF -> ON), randurile deja tinute sunt **eliberate automat**
|
||||||
|
spre transmitere.
|
||||||
|
- Vizibilitate: randurile tinute apar in coada cu o stare umana clara
|
||||||
|
("In asteptare (manual)"), separate de cele in curs de trimitere.
|
||||||
|
|
||||||
|
### Metrici de succes
|
||||||
|
- Cu Auto OFF, un `POST /v1/prezentari` valid creeaza un rand care **NU** e trimis de
|
||||||
|
worker (ramane vizibil in coada) pana la actiune umana.
|
||||||
|
- Cu Auto ON, acelasi rand pleaca la RAR fara interventie (zero regresie fata de azi).
|
||||||
|
- Bifa supravietuieste restartului (persistata in `accounts`), per-cont (un cont OFF nu
|
||||||
|
afecteaza alt cont).
|
||||||
|
|
||||||
|
## 3. Design (decizii luate cu utilizatorul)
|
||||||
|
|
||||||
|
| # | Decizie | Alegere |
|
||||||
|
|---|---------|---------|
|
||||||
|
| D1 | Default bifa "Auto" pe conturi (inclusiv noi) | **OFF** (manual) — sigur, nimic nu pleaca fara confirmare |
|
||||||
|
| D2 | La OFF -> ON, randurile deja tinute | **Eliberate automat** spre transmitere |
|
||||||
|
| D3 | Plasare in UI | **Bara de status** (langa contoare, ca in mockup gomag) |
|
||||||
|
| D4 | Trimitere manuala | **Per rand + buton "Trimite toate (N)"** |
|
||||||
|
| D5 | Persistenta | Bifa salvata **pe contul service-ului** (`accounts`) |
|
||||||
|
|
||||||
|
### Mecanica aleasa: flag `held` pe submission (NU stare noua)
|
||||||
|
|
||||||
|
Randurile tinute raman in starea `queued` (sunt logic in coada, doar puse pe pauza),
|
||||||
|
marcate cu o coloana booleana noua `submissions.held`. Motiv: evitam atingerea
|
||||||
|
CHECK-ului de stari si a masinii de stari (`queued/sending/sent/needs_mapping/
|
||||||
|
needs_data/error`), a pill-urilor, filtrelor si contoarelor — schimbare strict
|
||||||
|
**aditiva**. Eticheta umana "In asteptare (manual)" se deriva din `status='queued'
|
||||||
|
AND held=1` in stratul de afisaj (`labels.py`).
|
||||||
|
|
||||||
|
- **Comutatorul de cont** (`accounts.auto_send_enabled`) guverneaza DOAR:
|
||||||
|
(a) valoarea implicita a lui `held` la ingestie; (b) eliberarea in bloc la OFF -> ON.
|
||||||
|
- **Worker-ul** (`claim_one`) ia doar `status='queued' AND held=0`. Nu mai stie de
|
||||||
|
comutatorul de cont — ramane simplu si robust.
|
||||||
|
- **Trimiterea manuala** (per rand sau bulk) = `held: 1 -> 0`; worker-ul preia randul la
|
||||||
|
urmatorul poll. Functioneaza chiar daca contul e pe Auto OFF (override uman per rand).
|
||||||
|
|
||||||
|
Comutatorul global `AUTOPASS_WORKER_SEND_ENABLED` ramane **kill-switch master** (productia
|
||||||
|
il porneste). Feature-ul nou se aseaza DEASUPRA lui: held tine randul indiferent de env.
|
||||||
|
|
||||||
|
## 4. User Stories
|
||||||
|
|
||||||
|
### US-001: Schema — comutator cont + flag held
|
||||||
|
**Ca** dezvoltator
|
||||||
|
**Vreau** coloanele de persistenta pentru bifa Auto si pentru randurile tinute
|
||||||
|
**Pentru ca** starea sa supravietuiasca restartului si sa fie per-cont.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `accounts.auto_send_enabled INTEGER NOT NULL DEFAULT 0 CHECK (auto_send_enabled IN (0,1))`
|
||||||
|
adaugat in `app/schema.sql` + migrare defensiva in `app/db.py::_migrate` (ALTER
|
||||||
|
idempotent, ca la `email`/`tier`).
|
||||||
|
- [ ] `submissions.held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0,1))` adaugat +
|
||||||
|
migrare defensiva. Index partial `idx_submissions_held ON submissions(held) WHERE held=1`.
|
||||||
|
- [ ] **Index in `_migrate`, nu doar `schema.sql` (Eng MEDIUM):** `CREATE TABLE IF NOT EXISTS`
|
||||||
|
nu se declanseaza pe DB existent -> indexul partial trebuie creat si in `_migrate`
|
||||||
|
(ca `idx_submissions_batch` la `db.py:155`), altfel un DB prod upgradat capata coloana
|
||||||
|
(ALTER) dar NU si indexul.
|
||||||
|
- [ ] Contul implicit id=1 (dev) ramane pe default (0) — fara tratament special.
|
||||||
|
- [ ] Helperi in `app/accounts.py`: `get_auto_send(conn, account_id) -> bool` si
|
||||||
|
`set_auto_send(conn, account_id, enabled: bool)` (idempotent, scoped pe cont).
|
||||||
|
- [ ] `python3 -m pytest -q` ramane verde (migrare aditiva, fara regresie pe schema).
|
||||||
|
|
||||||
|
### US-002: Ingestie respecta comutatorul de cont
|
||||||
|
**Ca** operator de service cu Auto OFF
|
||||||
|
**Vreau** ca prezentarile noi sa intre in coada tinute (held=1)
|
||||||
|
**Pentru ca** sa le verific inainte sa plece la RAR.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] La INSERT-ul `status='queued'` pe canalul API (`app/api/v1/router.py`, ~l.282),
|
||||||
|
`held` = `0 daca accounts.auto_send_enabled=1 altfel 1` (snapshot la ingestie).
|
||||||
|
- [ ] Acelasi snapshot la commit-ul de import (`app/api/v1/import_router.py`, ~l.1193).
|
||||||
|
- [ ] La reresolve (un `needs_mapping` rezolvat trece pe `queued`, `app/mapping.py` ~l.895),
|
||||||
|
`held` se seteaza tot din comutatorul contului.
|
||||||
|
- [ ] `held` NU intra in `payload_json`, NU in `build_key`/idempotenta, NU in payload-ul
|
||||||
|
RAR — e pur control de coada (ca `reviewed` la import).
|
||||||
|
- [ ] **DRY + acoperire COMPLETA (review CEO + Eng Finding A — HIGH):** calculul `held` e UN
|
||||||
|
SINGUR helper `held_for_account(conn, account_id) -> int`, chokepoint pt. TOATE situri
|
||||||
|
`SET status='queued'`, nu doar 3. Codebase-ul are ~8 scriitori de `queued`:
|
||||||
|
`router.py:282` (enqueue), `import_router.py:1190` (commit), `mapping.py:895` (reresolve),
|
||||||
|
**`router.py:237` (reactivare error->queued la re-POST — BUG real de bypass: randul pastra
|
||||||
|
`held` VECHI -> se auto-trimite desi Auto OFF)**, si rutele web de operator
|
||||||
|
`routes.py` mapeaza-inline / corecteaza / repune / bulk-fix.
|
||||||
|
- [ ] **Politica rute operator:** pentru tranzitiile declansate de operator in panoul de
|
||||||
|
detaliu (corecteaza/repune/mapeaza/bulk-fix), `held=0` (actiunea operatorului = intentie
|
||||||
|
explicita de trimitere) — DAR e o DECIZIE documentata, nu o omisiune, si respecta UX-ul de
|
||||||
|
confirmare cand contul e OFF. Canalele de ingestie (API/import/reresolve/reactivare) =
|
||||||
|
`held_for_account`.
|
||||||
|
- [ ] `requeue_with_backoff` (worker `:154`) NU atinge `held` (tranzitie interna worker).
|
||||||
|
- [ ] **Echo pe dedup (Eng MEDIUM):** ramura de dedup (`router.py:264`, re-POST pe rand
|
||||||
|
existent) intoarce si ea `held` (azi ar da un "queued" curat fals — vezi US-009).
|
||||||
|
- [ ] Test: cont Auto OFF -> `POST /v1/prezentari` valid -> rand `queued, held=1`;
|
||||||
|
cont Auto ON -> `queued, held=0`.
|
||||||
|
- [ ] Test reresolve: cont Auto OFF, submission `needs_mapping` -> mapare salvata ->
|
||||||
|
rand devine `queued, held=1` (nu pleaca automat).
|
||||||
|
|
||||||
|
### US-003: Worker nu trimite randurile tinute
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** ca worker-ul sa sara peste randurile held=1
|
||||||
|
**Pentru ca** transmiterea sa astepte decizia umana.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `claim_one` (`app/worker/__main__.py`) adauga `AND s.held = 0` la `WHERE`-ul de claim.
|
||||||
|
- [ ] Test: rand `queued, held=1` cu cont `active` si send pornit -> `claim_one` intoarce
|
||||||
|
`None` (nu il ia); acelasi rand cu `held=0` -> e luat (`sending`).
|
||||||
|
- [ ] Recuperarea orfanilor / reconcilierea NU sunt afectate (held se aplica doar la claim
|
||||||
|
din `queued`; un rand deja `sending` ramane gestionat normal).
|
||||||
|
|
||||||
|
### US-004: Bifa "Auto" in bara de status (toggle + persistenta + auto-release)
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** o bifa "Auto" in bara de status, salvata pe cont
|
||||||
|
**Pentru ca** sa pornesc/opresc transmiterea automata dintr-un click.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Control checkbox HTMX cu eticheta vizibila **"Trimite automat la RAR"** (decizie user;
|
||||||
|
NU "Auto" — eviti coliziunea cu "Trimitere automata" worker din `labels.py`) + helptext
|
||||||
|
("Debifat: prezentarile asteapta confirmarea ta"), in clusterul de header langa
|
||||||
|
`.rar-chip` SAU pe rand propriu in bara de status (vezi D1). Reflecta
|
||||||
|
`accounts.auto_send_enabled` al contului din sesiune.
|
||||||
|
- [ ] `POST /auto-send` (ruta web, sub `require_login` + scope cont + CSRF) comuta bifa
|
||||||
|
si o **persista** pe cont; raspuns OOB care re-randeaza bara de status.
|
||||||
|
- [ ] La trecerea OFF -> ON: toate randurile `queued AND held=1` ale contului devin
|
||||||
|
`held=0` (eliberare in bloc), scoped strict pe contul curent. Eliberarea e o
|
||||||
|
SINGURA instructiune SQL atomica (`UPDATE ... WHERE account_id=? AND status='queued'
|
||||||
|
AND held=1`), NU un loop (review CEO: atomicitate + evita contention cu worker-ul).
|
||||||
|
- [ ] **Garda de confirmare (review CEO F4):** daca exista N>0 randuri tinute la
|
||||||
|
activarea Auto, comutatorul cere o confirmare explicita cu numarul si destinatia
|
||||||
|
("Activarea Auto trimite imediat N prezentari catre RAR PRODUCTIE — FINALIZATA e
|
||||||
|
ireversibila"). Fara confirmare, randurile tinute NU pleaca. Motiv: pe contul de
|
||||||
|
test, un OFF->ON necugetat ar arunca toate prezentarile de proba in RAR real.
|
||||||
|
- [ ] La trecerea ON -> OFF: randurile deja `queued held=0` NU sunt retrase (doar
|
||||||
|
ingestiile NOI vor fi tinute); randurile in `sending`/`sent` neatinse.
|
||||||
|
- [ ] Verify in browser: comuti bifa, se salveaza, ramane dupa refresh; cu OFF un rand nou
|
||||||
|
apare tinut; comutand pe ON randurile tinute pleaca.
|
||||||
|
|
||||||
|
### US-005: Trimitere manuala — per rand + "Trimite toate (N)"
|
||||||
|
**Ca** operator cu Auto OFF
|
||||||
|
**Vreau** sa trimit un rand tinut sau toate odata
|
||||||
|
**Pentru ca** sa eliberez selectiv sau in bloc spre RAR.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Buton "Trimite" pe fiecare rand `queued held=1` in lista de trimiteri/coada
|
||||||
|
(`_submissions.html` / `_coada.html`), scoped + CSRF.
|
||||||
|
- [ ] `POST /trimitere/{id}/trimite-acum`: 404-before-leak pe id strain; seteaza `held=0`
|
||||||
|
DOAR daca randul e `queued held=1` (no-op sigur altfel); OOB refresh.
|
||||||
|
- [ ] Buton bulk "Trimite toate (N)" (N = nr. randuri tinute ale contului) ->
|
||||||
|
`POST /trimite-toate`: elibereaza toate `queued AND held=1` ale contului (held=0),
|
||||||
|
cu confirmare tipata (count + "catre RAR PRODUCTIE", review CEO F5). Update atomic
|
||||||
|
scoped pe cont (NU poate elibera randuri ale altui cont).
|
||||||
|
- [ ] `POST /trimitere/{id}/trimite-acum` UPDATE include `AND status='queued'` ca un rand
|
||||||
|
deja `sending` (luat de worker intre afisaj si click) sa fie no-op sigur (edge race).
|
||||||
|
- [ ] Eliberarea seteaza doar `held=0`; worker-ul preia randul la urmatorul poll
|
||||||
|
(trimitere asincrona, ca azi). Necesita worker pornit + send master ON + cont activ.
|
||||||
|
- [ ] Butonul "Trimite toate (0)" e ascuns cand nu exista randuri tinute.
|
||||||
|
- [ ] Test: rand tinut -> `trimite-acum` -> `held=0`; apoi `claim_one` il ia.
|
||||||
|
|
||||||
|
### US-006: Afisaj stare "In asteptare (manual)"
|
||||||
|
**Ca** operator
|
||||||
|
**Vreau** sa disting randurile tinute de cele in curs de trimitere
|
||||||
|
**Pentru ca** sa stiu ce asteapta decizia mea.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `app/web/labels.py`: pentru `status='queued' AND held=1` -> eticheta umana
|
||||||
|
"In asteptare (manual)" + clasa CSS de avertizare (ca `needs_*`); `held=0` ramane
|
||||||
|
"In asteptare" (queued normal).
|
||||||
|
- [ ] Bara de status arata un contor separat "In asteptare (manual): N" cand N > 0
|
||||||
|
(derivat din `queued AND held=1`); contorul `queued` total ramane corect.
|
||||||
|
- [ ] Lista de trimiteri marcheaza randurile tinute (badge/pill), butonul "Trimite" apare
|
||||||
|
doar pe ele.
|
||||||
|
- [ ] Verify in browser: un rand tinut afiseaza eticheta corecta si butonul; dupa trimitere
|
||||||
|
trece la "In curs de trimitere" -> "Trimisa".
|
||||||
|
|
||||||
|
### US-007: Vizibilitate coada tinuta imbatranita (mitigare OBLIGATORIE pt. default OFF)
|
||||||
|
**Ca** operator / admin
|
||||||
|
**Vreau** un semnal vizibil cand prezentari raman tinute prea mult
|
||||||
|
**Pentru ca** default OFF (decizie user, pana devine stabil) lasa altfel prezentari
|
||||||
|
nedeclarate tacit — exact esecul silentios pe care L.142/2023 il face risc legal.
|
||||||
|
|
||||||
|
> Conditie: user a ales DELIBERAT default OFF "pana devine stabil" peste avertismentul de
|
||||||
|
> conformitate (review CEO F1/F3). Aceasta US e atenuarea agreata si e BLOCANTA, nu optionala.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Bara de status: cand exista randuri `queued AND held=1` mai vechi de `N` zile
|
||||||
|
(config `AUTOPASS_HELD_WARN_DAYS`, default 7), afiseaza un banner de avertizare
|
||||||
|
("M prezentari tinute de >N zile — declarare obligatorie L.142") cu deep-link la lista
|
||||||
|
filtrata pe tinute.
|
||||||
|
- [ ] `/metrics` expune un gauge `autopass_held_submissions` (total randuri tinute) si
|
||||||
|
`autopass_held_oldest_age_seconds` (varsta celui mai vechi rand tinut), scoped global
|
||||||
|
(observabilitate ops, review CEO F3).
|
||||||
|
- [ ] Bannerul + gauge sunt derivate (zero stare noua); contorul varstei foloseste
|
||||||
|
`created_at` al randului.
|
||||||
|
- [ ] Test: rand tinut cu `created_at` vechi -> bannerul apare; gauge raporteaza varsta.
|
||||||
|
|
||||||
|
### US-008: Retentie randuri tinute (inchide gaura GDPR/L.142, review CEO F6)
|
||||||
|
**Ca** sistem
|
||||||
|
**Vreau** ca randurile tinute la nesfarsit sa aiba o politica de expirare
|
||||||
|
**Pentru ca** un `queued held=1` nu e nici `sent` nici blocat -> azi NU primeste
|
||||||
|
`purge_after` -> PII criptat (si `rar_creds_enc` efemer pe canalul API) ar sta vesnic.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Worker-ul expira randurile `queued AND held=1` mai vechi de `held_retention_days`
|
||||||
|
(config, default 90, aliniat T16): le trece la `error` cu mesaj `TINUT_EXPIRAT`
|
||||||
|
(terminal) + **seteaza `purge_after` DIRECT la momentul expirarii** (NU lasa `mark()` sa
|
||||||
|
aplice `blocked_retention_days`=30). Eng MEDIUM: altfel viata reala = 90 (held) + 30
|
||||||
|
(error) = 120 zile, nu 90. Fie purge_after explicit la tranzitie, fie documenteaza 120.
|
||||||
|
- [ ] La eliberarea manuala/auto a unui rand tinut, daca `rar_creds_enc` (canal API) e prea
|
||||||
|
vechi, worker-ul cade pe `accounts.rar_creds_enc` (fallback re-login) ca azi — verificat
|
||||||
|
ca creds efemere expirate nu blocheaza trimiterea.
|
||||||
|
- [ ] Test: rand tinut vechi -> ciclul de purjare al worker-ului il expira + seteaza
|
||||||
|
`purge_after`; PII devine purjabil.
|
||||||
|
|
||||||
|
### US-009: Fixturi teste + jurnal audit (review CEO F7 + observabilitate)
|
||||||
|
**Ca** dezvoltator
|
||||||
|
**Vreau** ca suita existenta sa nu se blocheze pe default OFF si ca actiunile sa fie auditate
|
||||||
|
**Pentru ca** default OFF + `claim_one ... AND held=0` face ca lantul `POST -> claim -> sent`
|
||||||
|
din testele existente (+ `test_live_rar`) sa stagneze tacit daca nu setam Auto ON.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] `conftest`/factory de cont seteaza `auto_send_enabled=1` (sau `held=0`) pe conturile
|
||||||
|
folosite de testele care exercita lantul de trimitere; `test_live_rar` seteaza explicit
|
||||||
|
Auto ON. `pytest -q` ramane verde.
|
||||||
|
- [ ] **Subtilitate id=1 (Eng HIGH/test):** contul implicit id=1 e creat de `schema.sql`
|
||||||
|
(`INSERT OR IGNORE`), NU de `create_account` -> un fix care patcheaza doar factory-ul NU
|
||||||
|
acopera contul folosit de majoritatea testelor (`test_import_e2e`, `test_creds_delivery`,
|
||||||
|
`test_live_rar` ar stagna). Conftest face explicit `UPDATE accounts SET auto_send_enabled=1
|
||||||
|
WHERE id=1` (autouse). E un fix de STARE DB, nu env var (coloana e per-rand in `accounts`).
|
||||||
|
- [ ] Audit `app_events`: comutarea Auto (`auto_send_schimbat` cu valoarea + cont) si
|
||||||
|
eliberarile manuale/bulk (`held_eliberat` cu count) sunt jurnalizate (redactat, scoped).
|
||||||
|
- [ ] Echo onest pe canalul API (aliniat invariant 5.7): raspunsul `POST /v1/prezentari`
|
||||||
|
pentru un rand tinut indica starea reala (`held=true` / nota umana "tinut pentru
|
||||||
|
verificare"), nu un fals "queued" curat. Dev-ul ROAAUTO vede ca randul NU a plecat.
|
||||||
|
- [ ] Test: eveniment audit scris la toggle + la eliberare; raspuns API reflecta `held`.
|
||||||
|
|
||||||
|
### US-010: Onestitate + observabilitate pe canalul API (review DX Faza 3.5)
|
||||||
|
**Ca** dezvoltator ROAAUTO/VFP care integreaza prin `POST /v1/prezentari`
|
||||||
|
**Vreau** sa vad clar ca un rand e tinut si NU a plecat la RAR
|
||||||
|
**Pentru ca** azi un rand tinut intoarce byte-identic cu unul gata de auto-send
|
||||||
|
(`status:queued, erori:[]`) -> reintroduce exact bug-ul de succes-fals 5.7.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] **Camp `held: bool = False` pe `SubmissionResult`** (`models.py`) + plumbing din
|
||||||
|
`held_for_account` in `_rezultat_enqueue(..., held=...)` SI pe ramura de dedup
|
||||||
|
(`router.py:264`). Cand `held and status=='queued'`, `motiv` devine NON-null
|
||||||
|
(DX CRITICAL): mesaj uman "In asteptare — tinut pt verificare; NU trimis la RAR (Auto OFF)".
|
||||||
|
- [ ] **`held` in proiectiile GET** (`_PREZENTARE_FIELDS` `router.py:398` + lista `cols`
|
||||||
|
`router.py:369`): un dev care face `GET /v1/prezentari/{id}` vede `held=true`, nu un
|
||||||
|
`queued` etern fara semnal (DX HIGH).
|
||||||
|
- [ ] **Reutilizeaza vocabularul existent `AUTO_SEND_OPRIT`** (`errors.py:92`) pt. mesajul
|
||||||
|
held — NU inventa al treilea vocabular "auto_send" (DX + R6). Mesaj 3-niveluri
|
||||||
|
(problema/cauza/fix) pe `rar_error`/`motiv`.
|
||||||
|
- [ ] **Documentatie hub `/integrare`** (`integrare_examples.py`/`_integrare.html`): tabel
|
||||||
|
"De ce nu ajunge la RAR?" (held / needs_mapping / needs_data) + nota explicita "conturi
|
||||||
|
noi pornesc cu Auto OFF, randurile asteapta eliberare manuala" + cum verifici/comuti
|
||||||
|
(DX HIGH — altfel primul POST da 200/queued, dev-ul crede ca merge, nimic nu ajunge).
|
||||||
|
- [ ] (Optional, paritate API) endpoint de eliberare API simetric cu `/repune`
|
||||||
|
(`router.py:458`): `POST /v1/prezentari/{id}/trimite-acum`, scoped pe cont, 404-before,
|
||||||
|
no-op daca nu `queued AND held=1` — ca integratorul API sa nu fie fortat in browser.
|
||||||
|
- [ ] Test: held -> `held=true` + `motiv` non-null pe enqueue, dedup si GET (regresie ca
|
||||||
|
`test_queued_fara_erori_nemapate`).
|
||||||
|
|
||||||
|
## 5. Cerinte functionale
|
||||||
|
|
||||||
|
1. [REQ-001] Comutatorul "Auto" e per-cont, persistat in `accounts.auto_send_enabled`,
|
||||||
|
default 0 (OFF) inclusiv pentru conturi noi.
|
||||||
|
2. [REQ-002] Cu Auto OFF, orice ingestie care ar produce `queued` produce `queued held=1`.
|
||||||
|
3. [REQ-003] Worker-ul nu trimite niciodata un rand `held=1`.
|
||||||
|
4. [REQ-004] OFF -> ON elibereaza in bloc randurile tinute ale contului (atomic, scoped),
|
||||||
|
DAR cu confirmare tipata cand N>0 (count + "RAR PRODUCTIE"); ON -> OFF nu retrage
|
||||||
|
randuri deja eliberate.
|
||||||
|
5. [REQ-005] Operatorul poate elibera un rand tinut individual sau toate odata (bulk cu
|
||||||
|
confirmare).
|
||||||
|
6. [REQ-006] `held` nu influenteaza payload-ul RAR, idempotenta sau validarea — pur coada.
|
||||||
|
7. [REQ-007] Toate rutele noi sunt scoped pe contul din sesiune, sub `require_login`,
|
||||||
|
cu CSRF si 404-before-leak pe id strain. **`account_id` se deriva INTOTDEAUNA din sesiune,
|
||||||
|
NICIODATA dintr-un camp de formular** (Eng security): altfel un operator pe contul A ar
|
||||||
|
elibera in bloc randurile contului B postand `account_id=B`. Per-rand prin
|
||||||
|
`_get_submission_scoped` (404 inainte de UPDATE).
|
||||||
|
8. [REQ-008] Randurile tinute imbatranite sunt VIZIBILE (banner + `/metrics`) si au
|
||||||
|
politica de retentie/expirare (nu raman PII vesnic). Comutarea + eliberarile sunt
|
||||||
|
auditate in `app_events`.
|
||||||
|
|
||||||
|
## 6. Non-Goals (ce NU facem)
|
||||||
|
|
||||||
|
- **Fara interval/programare de sync** (dropdown "1 min" + buton "Start Sync" din gomag):
|
||||||
|
worker-ul autopass e continuu, nu pe interval. "Trimite toate" e analogul lui "Start Sync".
|
||||||
|
- **Fara stare noua de submission** (`held`/`tinut`): folosim flag boolean pe `queued`.
|
||||||
|
- **Fara comutator per-operatie sau per-canal**: granularitatea e per-cont (decizie D5).
|
||||||
|
(Nota: coloanele `auto_send` ramase pe `operations_mapping`/`operation_text_rules` sunt
|
||||||
|
neutralizate din 5.11 si NU se reactiveaza aici.)
|
||||||
|
- **Fara modificarea kill-switch-ului global** `AUTOPASS_WORKER_SEND_ENABLED`.
|
||||||
|
- **Fara retragerea randurilor deja in `sending`/`sent`** (FINALIZATA e terminal la RAR).
|
||||||
|
- **`held` NU e sandbox de testare** (avertisment de onestitate — tema cross-faza CEO F2 + DX4):
|
||||||
|
eliberarea unui rand tinut declara REAL la RAR (FINALIZATA ireversibila). "Tinut" doar
|
||||||
|
AMANA o trimitere reala. Ca sa testezi fara consecinte cu functia asta: tii randul si il
|
||||||
|
STERGI (nu-l eliberezi). **Decizie user (poarta finala): 5.19 = doar tinut operational**;
|
||||||
|
fara documentare `/valideaza` ca unealta de testare si fara rutare per-cont la RAR test
|
||||||
|
(`rar_env`). Acestea raman posibile follow-up-uri (TODOS), neangajate in 5.19.
|
||||||
|
|
||||||
|
## 7. Consideratii tehnice
|
||||||
|
|
||||||
|
### Stack / fisiere atinse
|
||||||
|
- Schema: `app/schema.sql` + `app/db.py::_migrate` (2 coloane aditive + 1 index).
|
||||||
|
- Backend cont: `app/accounts.py` (get/set toggle).
|
||||||
|
- Ingestie: `app/api/v1/router.py`, `app/api/v1/import_router.py`, `app/mapping.py`
|
||||||
|
(reresolve) — set `held` din comutator.
|
||||||
|
- Worker: `app/worker/__main__.py::claim_one` (+`AND s.held=0`).
|
||||||
|
- Web: `app/web/routes.py` (rute `/auto-send`, `/trimite-toate`,
|
||||||
|
`/trimitere/{id}/trimite-acum`), `app/web/labels.py`, template-uri
|
||||||
|
`_status.html` / `_submissions.html` / `_coada.html`.
|
||||||
|
|
||||||
|
### Patterns de urmat
|
||||||
|
- Migrare defensiva aditiva (model `accounts.email` / `accounts.tier` din 5.12/5.17).
|
||||||
|
- Rute web scoped + CSRF + OOB HTMX (model `submissions_admin.py` / butoanele de lifecycle 5.6).
|
||||||
|
- Strat de afisaj pur in `labels.py` (model 5.4) — fara logica de stare in template.
|
||||||
|
|
||||||
|
### Riscuri tehnice
|
||||||
|
- **R1 (default OFF schimba comportamentul):** azi nu exista hold; cu default 0, conturile
|
||||||
|
ar tine totul. Acceptabil — productia e pre-lansare, fara conturi legacy active
|
||||||
|
(cf. 5.17), iar utilizatorul vrea explicit OFF pe contul de test. Documentat ca
|
||||||
|
decizie constienta (D1).
|
||||||
|
- **R2 (reresolve scapa snapshot-ul):** daca uitam `held` pe calea de reresolve
|
||||||
|
(`mapping.py`), un rand deblocat din `needs_mapping` ar pleca automat desi contul e OFF.
|
||||||
|
Acoperit explicit de US-002 AC.
|
||||||
|
- **R3 (idempotenta):** `held` NU intra in cheie -> un re-`POST` al aceluiasi continut
|
||||||
|
loveste randul existent (dedup), nu creeaza dublura. Confirmat de invariantul `build_key`.
|
||||||
|
- **R4 (hazard de rollback — review CEO + Eng, HIGH operational):** daca se da revert pe cod
|
||||||
|
DUPA ce randuri au `held=1`, worker-ul pierde filtrul `AND held=0` -> ar trimite TOATE
|
||||||
|
randurile tinute la RAR (FINALIZATA ireversibila). Atenuare OBLIGATORIE: livreaza ODATA cu
|
||||||
|
feature-ul un helper `tools/` care carantineaza randurile tinute
|
||||||
|
(`UPDATE submissions SET status='error', rar_error='ROLLBACK_QUARANTINE' WHERE held=1`) +
|
||||||
|
pas de runbook scris in §9 (copy-paste, nu improvizat sub presiune).
|
||||||
|
- **R7 (eroziune creds efemere — Eng low-med):** la orice login reusit worker-ul NULL-eaza
|
||||||
|
TOATE `submissions.rar_creds_enc` ale contului (`worker:382`), nu doar randul trimis. Un cont
|
||||||
|
hibrid web+API cu keepalive-login poate sterge creds-urile efemere ale unui rand tinut API ->
|
||||||
|
la eliberare se cade pe `accounts.rar_creds_enc` (fallback). Acoperit de US-008, dar triggerul
|
||||||
|
e login-frate, nu varsta creds — de formulat corect.
|
||||||
|
- **R5 (contention SQLite la bulk release):** `UPDATE` masiv pe "Trimite toate" concureaza
|
||||||
|
cu `BEGIN IMMEDIATE` al worker-ului -> posibil `database is locked`. Update-ul atomic
|
||||||
|
(o instructiune) + retry/backoff scurt; sau chunking daca N e mare.
|
||||||
|
- **R6 (naming):** apare al TREILEA `auto_send` (cont `auto_send_enabled` vs
|
||||||
|
`operations_mapping.auto_send` vs `operation_text_rules.auto_send`). Comentariu clar in
|
||||||
|
`schema.sql` care le distinge, ca un viitor dezvoltator sa nu le confunde.
|
||||||
|
|
||||||
|
### Rafinari UI (review design Faza 2 — OBLIGATORII la implementare)
|
||||||
|
- **D1 (container real):** RAR dot e in `base.html` (header `.rar-chip`), NU in `_status.html`.
|
||||||
|
US-004 AC corectat: comutatorul Auto sta in clusterul de header langa `.rar-chip` (vizibilitate
|
||||||
|
maxima, langa semnalul RAR real) SAU pe un rand propriu etichetat in bara de status — NU
|
||||||
|
"langa dot" in `_status.html` (dot-ul nu e acolo).
|
||||||
|
- **D2 (toggle non-optimist):** checkbox HTMX flip-uie vizual indiferent de raspuns. Necesita
|
||||||
|
`hx-indicator` + revert-on-failure (la esec POST `/auto-send` -> bifa revine + toast eroare).
|
||||||
|
Fara fals-sigur tacit pe un comutator de transmitere guvernamentala.
|
||||||
|
- **D3 (poller nu inghite toggle-ul):** `#status-bar` are `hx-trigger="every 15s"` +
|
||||||
|
`hx-swap="outerHTML"` -> ar inlocui comutatorul la fiecare 15s (pierdere focus tastatura +
|
||||||
|
flicker). Exclude comutatorul din swap-ul periodic (container separat sau `hx-preserve`).
|
||||||
|
- **D4 (modal de confirmare real):** confirmarea tipata (count + "RAR PRODUCTIE") NU se poate
|
||||||
|
face cu `hx-confirm` (doar OK/Cancel nativ). Necesita un component modal (count, destinatie,
|
||||||
|
type-to-confirm) — adaugat in lista de fisiere. Per-rand "Trimite" primeste si el o
|
||||||
|
confirmare (1 linie + microcopy de ireversibilitate), nu doar bulk-ul.
|
||||||
|
- **D5 (camp derivat, nu in template):** `held` NU e stare noua -> pill-ul existent ar randa
|
||||||
|
"In coada" identic pt held si non-held. Calcul UN camp de afisaj derivat in `routes.py`
|
||||||
|
(regula "display layer pur"), nu in template. Culoare `--warn` (amber), NU clasa `needs_*`
|
||||||
|
(rosu/eroare) — held e asteptare benigna, nu eroare.
|
||||||
|
- **D6 (mobil 390px):** per-rand actiune = afordanta dedicata pe `.trimitere-slim` cu
|
||||||
|
`event.stopPropagation()` (randul e el insusi `role=button`), NU buton-copil nestat.
|
||||||
|
Al 6-lea contor "In asteptare (manual)" se pliaza in celula "In coada" pe bara compacta (nu
|
||||||
|
adauga a 6-a celula la 10px). Pill scurt ("Manual"/"Tinut") cu fraza completa in `title`.
|
||||||
|
- **D7 (ordonare bannere):** `_status.html` poate avea deja 3 bannere (cont inactiv / trial /
|
||||||
|
RAR jos) + al 4-lea (US-007 held). Regula de prioritate un-singur-banner ca sa nu impinga
|
||||||
|
contoarele sub fold pe mobil.
|
||||||
|
|
||||||
|
### Dependente
|
||||||
|
- Trimiterea manuala produce efect doar cu worker pornit + send master ON + cont `active`
|
||||||
|
(mediul de productie real). In dev (send OFF) randul eliberat ramane `queued held=0`.
|
||||||
|
|
||||||
|
## 8. Open Questions
|
||||||
|
|
||||||
|
- [ ] Trimiterea manuala se face asincron (flip `held=0`, worker preia la poll). Acceptam
|
||||||
|
latenta de un poll (cateva secunde) sau vrem feedback "in curs" imediat in UI?
|
||||||
|
(Propunere: asincron + OOB refresh, fara sincron — consistent cu arhitectura.)
|
||||||
|
- [ ] Pe mobil, butonul "Trimite" per rand + "Trimite toate" incap in layout-ul compact
|
||||||
|
(5.13)? (Propunere: "Trimite toate" in bara sticky, "Trimite" iconita pe card.)
|
||||||
|
|
||||||
|
## 9. Plan de verificare
|
||||||
|
|
||||||
|
- Regresie `python3 -m pytest -q` verde (baseline curent ~1392) + teste noi per story.
|
||||||
|
- E2E browser (Playwright, logat): comutare bifa persistenta dupa refresh; rand nou tinut
|
||||||
|
cu Auto OFF; eliberare per rand si bulk; tranzitie OFF -> ON elibereaza in bloc.
|
||||||
|
- Optional live RAR (`AUTOPASS_LIVE_RAR=1`): cont OFF -> rand tinut -> "Trimite" ->
|
||||||
|
`sent idPrezentare=...` confirmat in finalizate.
|
||||||
|
|
||||||
|
## 10. Decizii /autoplan — audit trail
|
||||||
|
|
||||||
|
Pipeline: CEO -> Design -> Eng -> DX, voce unica (Codex indisponibil pana 2026-07-18, plafon
|
||||||
|
utilizare). Deciziile intermediare auto-decise pe 6 principii; portile umane = premise + taste.
|
||||||
|
|
||||||
|
### Poarta de premise (decizia ta)
|
||||||
|
- **Scop:** AMBELE — control operational permanent + ajutor de testare.
|
||||||
|
- **Default Auto:** OFF, pastrat "pana devine stabil" (ales constient peste avertismentul de
|
||||||
|
conformitate L.142). Inverseaza recomandarea CEO F1 (default ON). Acceptat ca decizie de
|
||||||
|
domeniu; declanseaza atenuari OBLIGATORII (US-007/008/009).
|
||||||
|
|
||||||
|
### Decizii auto (6 principii)
|
||||||
|
| # | Faza | Decizie | Clasif. | Principiu | Motiv |
|
||||||
|
|---|------|---------|---------|-----------|-------|
|
||||||
|
| 1 | CEO | Approach A (held boolean) ca baza, nu stare noua (B) sau enum mod (C) | Mecanica | P5+P3 | aditiv, reuse pattern `reviewed`; B atinge masina de stari pazita |
|
||||||
|
| 2 | CEO | US-007 vizibilitate coada imbatranita OBLIGATORIE | Mecanica | P1+observ | atenuarea agreata pt default OFF; inchide esecul silentios F3 |
|
||||||
|
| 3 | CEO | US-008 retentie randuri tinute | Mecanica | P1 | F6: held nu primeste `purge_after` -> PII vesnic (GDPR/L.142) |
|
||||||
|
| 4 | CEO | US-009 fixturi teste Auto ON + audit + echo API held | Mecanica | P1 | F7: default OFF stagneaza testele; invariant 5.7 raspuns onest |
|
||||||
|
| 5 | CEO | Garda de confirmare OFF->ON + bulk (count + RAR PRODUCTIE) | Mecanica | P1 | F4/F5: flush ireversibil de randuri test in RAR real |
|
||||||
|
| 6 | CEO | `held_for_account()` helper unic (DRY) | Mecanica | P4 | calcul held inline de 3x = sit uitat trimite automat |
|
||||||
|
| 7 | CEO | Enum mod cont (live/hold/test) -> TODOS | Mecanica | P3 | scope dincolo de cerere; dream-state, nu blocant |
|
||||||
|
|
||||||
|
### Decizii de taste / provocari -> poarta finala (Faza 4)
|
||||||
|
- **T-EXP1 (reframe testare, CEO F2 + DX4) -> REZOLVAT: user a ales "doar tinut".** Nici
|
||||||
|
`rar_env`, nici documentarea `/valideaza` ca unealta de testare in 5.19. Ambele -> TODOS
|
||||||
|
(posibil follow-up). Pastrat doar avertismentul de onestitate ca eliberarea declara real.
|
||||||
|
- **T-LABEL (eticheta toggle, Design HIGH) -> REZOLVAT: user a ales REDENUMIREA.** Eticheta
|
||||||
|
vizibila = **"Trimite automat la RAR"** (nu "Auto"), ca sa nu se ciocneasca cu
|
||||||
|
"Trimitere automata" (worker viu) din `labels.py`. Conceptul/coloana raman `auto_send_enabled`.
|
||||||
|
|
||||||
|
### Faze Design/Eng/DX (audit)
|
||||||
|
| Faza | Decizie cheie | Clasif. | Motiv |
|
||||||
|
|------|---------------|---------|-------|
|
||||||
|
| Design | D1-D7 rafinari UI (non-optimist, poller, modal, mobil, camp derivat, --warn, bannere) | Mecanica | structural, P5 explicit |
|
||||||
|
| Eng | held_for_account la TOATE ~8 situri queued (bug reactivare router:237) | Mecanica | P5; bypass real Auto OFF |
|
||||||
|
| Eng | conftest UPDATE id=1; index in _migrate; purge_after direct; account_id din sesiune | Mecanica | corectitudine/securitate |
|
||||||
|
| DX | held pe SubmissionResult+GET; reuse AUTO_SEND_OPRIT; hub docs | Mecanica | P1; invariant 5.7 |
|
||||||
|
|
||||||
|
### Sumar completare review
|
||||||
|
```
|
||||||
|
+====================================================================+
|
||||||
|
| /autoplan — MEGA PLAN REVIEW — COMPLETION SUMMARY |
|
||||||
|
+====================================================================+
|
||||||
|
| Mod | SELECTIVE EXPANSION |
|
||||||
|
| Voci | Claude subagent (CEO/Design/Eng/DX); |
|
||||||
|
| | Codex INDISPONIBIL (plafon -> 2026-07-18) |
|
||||||
|
| Poarta premise | scop=AMBELE; default OFF (user, time-boxed) |
|
||||||
|
| CEO | 7 findings, 2 critice -> atenuate |
|
||||||
|
| Design | 5->8/10; 13 findings, 3 critice -> D1-D7 |
|
||||||
|
| Eng | 7 issues; 1 BUG real (reactivare bypass) |
|
||||||
|
| DX | 5->8/10; onestitate API -> US-010 |
|
||||||
|
| Stories | 6 -> 10 (US-007/008/009/010 adaugate) |
|
||||||
|
| Taskuri | 26 (14 P1, 9 P2, 3 P3), agregate pe faze |
|
||||||
|
| Tema cross-faza | hold != sandbox testare (CEO F2 + DX4) |
|
||||||
|
| Taste rezolvate | T-EXP1=doar tinut; T-LABEL=redenumire |
|
||||||
|
| Deferate (TODOS) | enum mod cont; rar_env; doc /valideaza |
|
||||||
|
| Test plan | scris pe disc (~/.gstack/.../test-plan) |
|
||||||
|
| Artefacte taskuri | 4 JSONL pe faza |
|
||||||
|
| Decizii nerezolvate | 0 |
|
||||||
|
+====================================================================+
|
||||||
|
```
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | issues_open->resolved | 5 propuneri, 4 acceptate, 2 deferate; 2 gap critice atenuate |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | issues_open->resolved | 7 issues (1 bug bypass reactivare), 0 gap critice ramase |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | issues_open->resolved | 5->8/10, 13 findings (3 critice) -> D1-D7 |
|
||||||
|
| DX Review | `/plan-devex-review` | Developer experience gaps | 1 | issues_open->resolved | 5->8/10, onestitate API (US-010) |
|
||||||
|
|
||||||
|
- **CROSS-MODEL:** N/A — Codex indisponibil (plafon utilizare pana 2026-07-18); voce unica Claude subagent pe toate fazele.
|
||||||
|
- **VERDICT:** CEO + DESIGN + ENG + DX CLEARED (voce unica) — PRD revizuit, gata de implementare. Toate deciziile portilor inchise cu user.
|
||||||
|
|
||||||
|
NO UNRESOLVED DECISIONS
|
||||||
@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
|
|||||||
tok = _csrf(client, "/signup")
|
tok = _csrf(client, "/signup")
|
||||||
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
|
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
|
||||||
"email": email, "parola": password,
|
"email": email, "parola": password,
|
||||||
"csrf_token": tok}, follow_redirects=True)
|
"consent": "1", "csrf_token": tok},
|
||||||
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def _signup(client: TestClient, name: str, email: str, password: str = "parola_t
|
|||||||
"cui": make_test_cui(email),
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": password,
|
"parola": password,
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
||||||
@@ -261,3 +262,158 @@ def test_activare_cont_incomplet_refuzata(client):
|
|||||||
assert not _get_account_active(incomplete_id), (
|
assert not _get_account_active(incomplete_id), (
|
||||||
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tier_trial(account_id: int) -> tuple[str, str | None]:
|
||||||
|
"""Citeste (tier, trial_until) din DB."""
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
|
||||||
|
).fetchone()
|
||||||
|
return (row["tier"], row["trial_until"]) if row else ("", None)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tier(account_id: int) -> str:
|
||||||
|
"""Citeste accounts.tier din DB."""
|
||||||
|
return _get_tier_trial(account_id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_din_admin_incheie_trial(client):
|
||||||
|
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
||||||
|
|
||||||
|
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
||||||
|
sa aiba efect imediat (decizie user 2026-06-29)."""
|
||||||
|
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
||||||
|
tier0, trial0 = _get_tier_trial(target_id)
|
||||||
|
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
||||||
|
assert trial0, "cont nou trebuie sa aiba trial_until setat (trial Pro 30z)"
|
||||||
|
|
||||||
|
admin_id = _signup(client, "Admin Tier SA", "admintier@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintier@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "pro",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "pro", "tier-ul nu a fost mutat pe pro"
|
||||||
|
assert trial1 is None, "trial_until trebuie sters la alocarea manuala (efect imediat)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_free_opreste_pro_imediat(client):
|
||||||
|
"""Setarea pe 'free' pe un cont in trial -> efectiv 'free' acum (trial incheiat).
|
||||||
|
|
||||||
|
Fara stergerea trial-ului, effective_tier ar fi ramas 'pro' inca ~30 zile."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.plans import effective_tier
|
||||||
|
|
||||||
|
target_id = _signup(client, "Firma Abuz Trial SRL", "abuztrial@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin Stop SA", "adminstop@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "adminstop@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "free",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "free" and trial1 is None
|
||||||
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||||
|
assert eff == "free", "dupa setarea pe free, planul efectiv trebuie sa fie free imediat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_invalid_respins(client):
|
||||||
|
"""Tier invalid -> nu schimba nimic (re-randare cu eroare sau ignorat)."""
|
||||||
|
target_id = _signup(client, "Firma Tier Invalid SRL", "tierinvalid@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin TI SA", "adminti@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "adminti@test.ro")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "platinum", # invalid
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 422), f"tier invalid ar trebui respins, primit {resp.status_code}"
|
||||||
|
assert _get_tier(target_id) == "free", "tier invalid nu trebuie aplicat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_tier_fara_csrf_respins(client):
|
||||||
|
"""POST /admin/set-tier fara token CSRF valid -> respins, tier neschimbat."""
|
||||||
|
target_id = _signup(client, "Firma CSRF Tier SRL", "csrftier@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin CSRF SA", "admincsrf@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admincsrf@test.ro")
|
||||||
|
|
||||||
|
resp = client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"tier": "pro",
|
||||||
|
"csrf_token": "token-fals",
|
||||||
|
})
|
||||||
|
assert resp.status_code in (400, 403), f"CSRF invalid trebuie respins, primit {resp.status_code}"
|
||||||
|
assert _get_tier(target_id) == "free", "tier schimbat desi CSRF era invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_trial_din_admin(client):
|
||||||
|
"""POST /admin/set-trial -> trial_until setat, tier de baza neschimbat, efectiv pro, 303."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.plans import effective_tier
|
||||||
|
|
||||||
|
target_id = _signup(client, "Firma Trial SRL", "trialnou@test.ro")
|
||||||
|
# incheie intai orice trial (set-tier free) ca sa pornim de la baza curata
|
||||||
|
admin_id = _signup(client, "Admin Trial SA", "admintrialacord@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintrialacord@test.ro")
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert _get_tier_trial(target_id) == ("free", None)
|
||||||
|
|
||||||
|
# acorda trial Pro 15 zile
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-trial", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"trial_days": "15",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
||||||
|
tier1, trial1 = _get_tier_trial(target_id)
|
||||||
|
assert tier1 == "free", "tier-ul de baza NU trebuie schimbat de acordarea de trial"
|
||||||
|
assert trial1, "trial_until trebuie setat"
|
||||||
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
||||||
|
assert eff == "pro", "trial activ trebuie sa ridice planul efectiv la pro"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_trial_zile_invalide_respins(client):
|
||||||
|
"""trial_days <= 0 -> 422, trial neschimbat."""
|
||||||
|
target_id = _signup(client, "Firma Trial Invalid SRL", "trialinvalid@test.ro")
|
||||||
|
admin_id = _signup(client, "Admin TInv SA", "admintinv@test.ro")
|
||||||
|
_make_admin(admin_id)
|
||||||
|
_login(client, "admintinv@test.ro")
|
||||||
|
# porneste de la trial sters
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
client.post("/admin/set-tier", data={
|
||||||
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
||||||
|
})
|
||||||
|
csrf = _get_csrf(client, "/admin")
|
||||||
|
resp = client.post("/admin/set-trial", data={
|
||||||
|
"account_id": str(target_id),
|
||||||
|
"trial_days": "0",
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert _get_tier_trial(target_id) == ("free", None), "trial nu trebuie setat la zile invalide"
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def test_signup_fara_cui_422(client):
|
|||||||
"cui": "",
|
"cui": "",
|
||||||
"email": "fara_cui@test.com",
|
"email": "fara_cui@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
|
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
|
||||||
@@ -96,6 +97,7 @@ def test_signup_scrie_email_pe_account(client):
|
|||||||
"cui": "RO9999001",
|
"cui": "RO9999001",
|
||||||
"email": "cu_email@test.com",
|
"email": "cu_email@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -131,6 +133,7 @@ def test_signup_email_duplicat_mesaj_email(client):
|
|||||||
"cui": make_test_cui("email-dup-c1"),
|
"cui": make_test_cui("email-dup-c1"),
|
||||||
"email": "emaildup@test.com",
|
"email": "emaildup@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp1.status_code == 200
|
assert resp1.status_code == 200
|
||||||
@@ -145,6 +148,7 @@ def test_signup_email_duplicat_mesaj_email(client):
|
|||||||
"cui": cui_nou,
|
"cui": cui_nou,
|
||||||
"email": "emaildup@test.com",
|
"email": "emaildup@test.com",
|
||||||
"parola": "parolasecreta456",
|
"parola": "parolasecreta456",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token2,
|
"csrf_token": token2,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
|
|||||||
"cui": "RO8888001",
|
"cui": "RO8888001",
|
||||||
"email": "firma1@test.com",
|
"email": "firma1@test.com",
|
||||||
"parola": "parolasecreta123",
|
"parola": "parolasecreta123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +195,7 @@ def test_signup_cui_existent_mesaj_prietenos(client):
|
|||||||
"cui": "RO8888001",
|
"cui": "RO8888001",
|
||||||
"email": "firma2@test.com",
|
"email": "firma2@test.com",
|
||||||
"parola": "parolasecreta456",
|
"parola": "parolasecreta456",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token2,
|
"csrf_token": token2,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecret
|
|||||||
"cui": make_test_cui(email),
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": parola,
|
"parola": parola,
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"):
|
|||||||
from tests.conftest import make_test_cui
|
from tests.conftest import make_test_cui
|
||||||
tok = _csrf(client, "/signup")
|
tok = _csrf(client, "/signup")
|
||||||
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
|
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
|
||||||
"parola": password, "csrf_token": tok}, follow_redirects=True)
|
"parola": password, "consent": "1", "csrf_token": tok},
|
||||||
|
follow_redirects=True)
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def test_signup_creeaza_cont_user_si_cheie(client):
|
|||||||
"cui": "RO12345678",
|
"cui": "RO12345678",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -87,6 +88,7 @@ def test_signup_email_duplicat_eroare(client):
|
|||||||
"cui": make_test_cui("dup@example.com"),
|
"cui": make_test_cui("dup@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ def test_signup_email_duplicat_eroare(client):
|
|||||||
"cui": make_test_cui("dup-b@example.com"),
|
"cui": make_test_cui("dup-b@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "altaparola123",
|
"parola": "altaparola123",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp2.status_code in (200, 422)
|
assert resp2.status_code in (200, 422)
|
||||||
@@ -139,6 +142,72 @@ def test_signup_parola_scurta_eroare(client):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_fara_consent_eroare(client):
|
||||||
|
"""Consimtamant GDPR lipsa -> 422, fara creare cont; mesaj despre Termeni/GDPR.
|
||||||
|
|
||||||
|
Checkbox-ul de consimtamant trebuie validat server-side (functional, nu doar client-side):
|
||||||
|
fara el contul nu se creeaza si planul/datele introduse se pastreaza in re-render.
|
||||||
|
"""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
|
resp = client.get("/signup")
|
||||||
|
token = _csrf(resp.text)
|
||||||
|
|
||||||
|
resp = client.post("/signup", data={
|
||||||
|
"name": "Service Fara Consent",
|
||||||
|
"cui": make_test_cui("fara-consent@test.com"),
|
||||||
|
"email": "fara-consent@test.com",
|
||||||
|
"parola": "parolasecreta123",
|
||||||
|
# fara "consent"
|
||||||
|
"csrf_token": token,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "rfak_" not in resp.text
|
||||||
|
assert "GDPR" in resp.text or "Termeni" in resp.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct = conn.execute(
|
||||||
|
"SELECT * FROM accounts WHERE name='Service Fara Consent'"
|
||||||
|
).fetchone()
|
||||||
|
assert acct is None, "Cont creat desi consimtamantul lipsea"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_salveaza_requested_plan_si_consent(client):
|
||||||
|
"""POST /signup cu plan ales -> accounts.requested_plan = codul ales, consent_at setat,
|
||||||
|
iar tier RAMANE 'free' (planul cerut NU acorda drepturi)."""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
|
resp = client.get("/signup")
|
||||||
|
token = _csrf(resp.text)
|
||||||
|
|
||||||
|
resp = client.post("/signup", data={
|
||||||
|
"name": "Service Plan Pro",
|
||||||
|
"cui": make_test_cui("plan-pro@test.com"),
|
||||||
|
"email": "plan-pro@test.com",
|
||||||
|
"parola": "parolasecreta123",
|
||||||
|
"plan": "pro",
|
||||||
|
"consent": "1",
|
||||||
|
"csrf_token": token,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "rfak_" in resp.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct = conn.execute(
|
||||||
|
"SELECT * FROM accounts WHERE name='Service Plan Pro'"
|
||||||
|
).fetchone()
|
||||||
|
assert acct is not None
|
||||||
|
assert acct["requested_plan"] == "pro", "Planul cerut nu a fost salvat"
|
||||||
|
assert acct["tier"] == "free", "tier NU trebuie urcat din planul cerut (doar dupa plata)"
|
||||||
|
assert acct["consent_at"], "consent_at trebuie setat la signup cu consimtamant"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def test_cheie_afisata_o_data(client):
|
def test_cheie_afisata_o_data(client):
|
||||||
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
||||||
from tests.conftest import make_test_cui
|
from tests.conftest import make_test_cui
|
||||||
@@ -150,6 +219,7 @@ def test_cheie_afisata_o_data(client):
|
|||||||
"cui": make_test_cui("cheie@test.com"),
|
"cui": make_test_cui("cheie@test.com"),
|
||||||
"email": "cheie@test.com",
|
"email": "cheie@test.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
|
"consent": "1",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
})
|
})
|
||||||
assert resp_post.status_code == 200
|
assert resp_post.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user