feat(5.16+5.17): tipografie/antet branded + tipuri cont, planuri si enforcement
PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat): - US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero @font-face si zero /static/fonts/; landing aliniat la acelasi stack - US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat (invariant zero-silent-failures pastrat) - US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan; meniu burger cu separatoare; gate strict pe is_authenticated - US-011: selector tema pill icon+eticheta (reuse THEMES) - US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod operatii, cod ales se salveaza fara "+", Renunta inchide via closest) - US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni - fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR: - US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage, CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit) - US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil); valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch) - US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO pluralizat + banner one-time trial->Gratuit + pagina Cont Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat. Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18 (corpus kNN) ramane separat, necomis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from .. import __version__
|
||||
from .. import errors as _errors
|
||||
from ..auth import rotate_api_key
|
||||
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
|
||||
from ..payload_view import prezentare_din_payload
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from .labels import (
|
||||
@@ -374,7 +375,7 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
account_meta = _fetch_account_meta(conn, acct)
|
||||
return templates.get_template("_cont.html").render({
|
||||
cont_ctx = {
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"api_key": None,
|
||||
@@ -385,7 +386,10 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
||||
"account_meta": account_meta,
|
||||
"date_firma_mesaj": None,
|
||||
"date_firma_eroare": None,
|
||||
})
|
||||
}
|
||||
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
|
||||
cont_ctx.update(_plan_ctx(conn, account_id))
|
||||
return templates.get_template("_cont.html").render(cont_ctx)
|
||||
|
||||
|
||||
def _render_panel_nomenclator(request: Request, conn) -> str:
|
||||
@@ -531,6 +535,139 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta
|
||||
return _render_panel_acasa(request)
|
||||
|
||||
|
||||
# Etichete tier pentru badge in antet (US-010 PRD 5.16).
|
||||
_TIER_LABELS: dict[str, str] = {
|
||||
"free": "Gratuit",
|
||||
"standard": "Standard",
|
||||
"pro": "Pro",
|
||||
"premium": "Premium",
|
||||
}
|
||||
|
||||
|
||||
def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict:
|
||||
"""Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger.
|
||||
|
||||
Returneaza:
|
||||
plan_linie — linie completa cu copy RO (cele 6 stari)
|
||||
plan_warn — True la >=80% consum sau limita atinsa (culoare + text)
|
||||
plan_limita_atinsa — True la 100% consum (--err in loc de --warn)
|
||||
trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time)
|
||||
usage_lunar — numar prestatii acceptate in coada luna curenta
|
||||
monthly_limit_val — limita lunara (60 pt free, None pt nelimitat)
|
||||
effective_tier_name — tier-ul efectiv ('free','standard','pro','premium')
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
acct = account_or_default(account_id)
|
||||
row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
|
||||
tier_base = (row["tier"] if row else None) or "free"
|
||||
trial_until_str = (row["trial_until"] if row else None)
|
||||
|
||||
eff = _eff_tier(row, now) if row else "free"
|
||||
monthly_limit = _PLANS.get(eff, _PLANS["free"]).get("monthly_limit")
|
||||
|
||||
usage = _monthly_usage(conn, acct, now)
|
||||
|
||||
# Calcul zile ramase din trial activ
|
||||
trial_ultima_zi = False
|
||||
trial_days: int | None = None
|
||||
if trial_until_str and eff == "pro" and tier_base == "free":
|
||||
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)
|
||||
delta = tu - now_cmp
|
||||
trial_days = delta.days # 0 = < 1 zi ramasa (azi), 1 = < 2 zile, etc.
|
||||
trial_ultima_zi = (trial_days <= 0)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Construieste plan_linie si stari aferente (cele 6 stari din PRD)
|
||||
warn_aproape = False
|
||||
plan_limita_atinsa = False
|
||||
trial_expirat_recent = False
|
||||
|
||||
if eff == "pro" and tier_base == "free" and trial_until_str:
|
||||
# Trial Pro activ
|
||||
if trial_ultima_zi:
|
||||
plan_linie = "Plan: Pro · trial expira azi"
|
||||
else:
|
||||
n = trial_days or 0
|
||||
z = "zi" if n == 1 else "zile"
|
||||
plan_linie = f"Plan: Pro · trial {n} {z} ramase"
|
||||
elif eff == "free":
|
||||
# Free — cu sau fara trial expirat recent
|
||||
if trial_until_str:
|
||||
trial_expirat_recent = True
|
||||
if monthly_limit is not None:
|
||||
if usage >= monthly_limit:
|
||||
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — limita atinsa"
|
||||
warn_aproape = True
|
||||
plan_limita_atinsa = True
|
||||
elif monthly_limit > 0 and usage >= int(monthly_limit * 0.8):
|
||||
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} — aproape de limita"
|
||||
warn_aproape = True
|
||||
else:
|
||||
plan_linie = f"Plan: Gratuit · {usage}/{monthly_limit} luna asta"
|
||||
else:
|
||||
plan_linie = "Plan: Gratuit"
|
||||
else:
|
||||
# Platit (tier de baza != free, ex. standard/pro/premium alocat de admin)
|
||||
label = _PLANS.get(eff, {}).get("label", eff.capitalize())
|
||||
plan_linie = f"Plan: {label}"
|
||||
|
||||
return {
|
||||
"plan_linie": plan_linie,
|
||||
"plan_warn": warn_aproape,
|
||||
"plan_limita_atinsa": plan_limita_atinsa,
|
||||
"trial_expirat_recent": trial_expirat_recent,
|
||||
"usage_lunar": usage,
|
||||
"monthly_limit_val": monthly_limit,
|
||||
"effective_tier_name": eff,
|
||||
}
|
||||
|
||||
|
||||
def _layout_header_ctx(conn, account_id: int) -> dict:
|
||||
"""Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16).
|
||||
|
||||
Citeste account_name, tier si starea de sanatate RAR pentru a popula:
|
||||
- account_name: numele service-ului, afisat sub titlu cand logat
|
||||
- tier_label: eticheta planului (Gratuit/Standard/Pro/Premium)
|
||||
- sanatate_ok: True daca worker viu si RAR ok (dot verde in antet)
|
||||
- last_login: data/ora ultimei autentificari RAR (format romanesc)
|
||||
- plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger
|
||||
|
||||
Apelat aditiv din dashboard() fara a atinge alti handlere.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT name, tier FROM accounts WHERE id=?", (account_id,)
|
||||
).fetchone()
|
||||
account_name = (row["name"] if row else None) or ""
|
||||
tier = (row["tier"] if row else "free") or "free"
|
||||
tier_label = _TIER_LABELS.get(tier, "Gratuit")
|
||||
|
||||
hb = read_heartbeat(conn)
|
||||
worker_alive = _worker_alive(hb)
|
||||
rar_state = _rar_state(hb, worker_alive)
|
||||
rar_ok = rar_state == "ok"
|
||||
sanatate_ok = worker_alive and rar_ok
|
||||
|
||||
ctx = {
|
||||
"account_name": account_name,
|
||||
"tier_label": tier_label,
|
||||
"sanatate_ok": sanatate_ok,
|
||||
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
||||
}
|
||||
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
|
||||
ctx.update(_plan_ctx(conn, account_id))
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
|
||||
"""Dashboard principal cu tab-uri.
|
||||
@@ -578,6 +715,9 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
# US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR).
|
||||
# Adaugat aditiv, fara a atinge handlerele altora.
|
||||
ctx.update(_layout_header_ctx(conn, account_id))
|
||||
return templates.TemplateResponse("dashboard.html", ctx)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -749,7 +889,7 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
|
||||
else:
|
||||
sanatate_text = "Declaratiile curg normal"
|
||||
|
||||
return {
|
||||
status_ctx = {
|
||||
"request": request,
|
||||
"worker_lbl": worker_lbl,
|
||||
"rar_lbl": rar_lbl,
|
||||
@@ -771,6 +911,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
|
||||
"mapari_badge": counts.get("needs_mapping", 0),
|
||||
"oob": oob,
|
||||
}
|
||||
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
|
||||
status_ctx.update(_plan_ctx(conn, account_id))
|
||||
return status_ctx
|
||||
|
||||
|
||||
@router.get("/_fragments/status", response_class=HTMLResponse)
|
||||
@@ -1363,6 +1506,18 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
||||
c.strip().upper() if isinstance(c, str) else ""
|
||||
for c in codes_raw
|
||||
]
|
||||
# US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare.
|
||||
# Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit.
|
||||
# Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional.
|
||||
# Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899).
|
||||
_flat_picker = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
||||
if _flat_picker and _flat_picker not in codes_positional:
|
||||
codes_positional.append(_flat_picker)
|
||||
for _i in range(len(codes_positional)):
|
||||
if not codes_positional[_i]:
|
||||
_op_val = str(form.get(f"chips_add_cod_{_i}") or "").strip().upper()
|
||||
if _op_val:
|
||||
codes_positional[_i] = _op_val
|
||||
# Verifica daca cel putin un cod non-gol a fost trimis
|
||||
codes_nonempty = [c for c in codes_positional if c]
|
||||
if codes_nonempty:
|
||||
@@ -1923,6 +2078,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
|
||||
})
|
||||
|
||||
action = str(form.get("chips_action") or "").strip()
|
||||
chips_extra_error = False # T-C1/T-E4 (5.16): semnal pentru add_extra esuat
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -1950,6 +2106,28 @@ async def post_form_chips(request: Request) -> HTMLResponse:
|
||||
if exists:
|
||||
chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""})
|
||||
|
||||
elif action == "add_extra":
|
||||
# US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii.
|
||||
# Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat).
|
||||
# T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil).
|
||||
add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper()
|
||||
if add_cod_extra:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (add_cod_extra,)
|
||||
).fetchone()
|
||||
if exists:
|
||||
# Dedup per-item (E4): nu adauga un chip extra identic deja existent
|
||||
existing_pairs = {
|
||||
(c.get("cod_op_service", ""), c.get("cod_prestatie", ""))
|
||||
for c in chips
|
||||
}
|
||||
if ("", add_cod_extra) not in existing_pairs:
|
||||
chips.append({"cod_prestatie": add_cod_extra, "cod_op_service": "", "denumire": ""})
|
||||
else:
|
||||
chips_extra_error = True # cod necunoscut in nomenclator
|
||||
else:
|
||||
chips_extra_error = True # select gol
|
||||
|
||||
elif action == "remove":
|
||||
# Sterge codul de la indexul dat (lasa op_service intact -> operatie ramane nemapata)
|
||||
try:
|
||||
@@ -1983,6 +2161,7 @@ async def post_form_chips(request: Request) -> HTMLResponse:
|
||||
"has_r_odo": has_r_odo,
|
||||
"form_chips_url": "/form-chips",
|
||||
"chips_section_id": "chips-section",
|
||||
"chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16)
|
||||
})
|
||||
|
||||
|
||||
@@ -3525,6 +3704,46 @@ async def web_confirma_import(
|
||||
|
||||
n_total_ok = len(to_enqueue)
|
||||
|
||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). Canal web.
|
||||
from ..config import get_settings as _get_settings_plan
|
||||
from ..plans import PLANS as _PLANS, effective_tier as _effective_tier, monthly_usage as _monthly_usage
|
||||
_plan_settings = _get_settings_plan()
|
||||
if _plan_settings.enforce_plans and n_total_ok > 0:
|
||||
from datetime import datetime, timezone as _tz
|
||||
_acct_row = conn.execute(
|
||||
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
_now_plan = datetime.now(_tz.utc)
|
||||
_et = _effective_tier(_acct_row, _now_plan)
|
||||
_plan_limit = _PLANS[_et].get("monthly_limit")
|
||||
if _plan_limit is not None:
|
||||
_usage = _monthly_usage(conn, acct, _now_plan)
|
||||
if _usage + n_total_ok > _plan_limit:
|
||||
_remaining = max(0, _plan_limit - _usage)
|
||||
log_event(
|
||||
"plan_limita_lunara_atinsa",
|
||||
account_id=acct,
|
||||
nivel="WARNING",
|
||||
mesaj=f"Import web de {n_total_ok} respins (usage={_usage}, limita={_plan_limit})",
|
||||
context={
|
||||
"n_to_enqueue": n_total_ok, "usage": _usage,
|
||||
"plan_limit": _plan_limit, "tier": _et,
|
||||
},
|
||||
conn=conn,
|
||||
)
|
||||
_err_msg = (
|
||||
f"Ai atins limita planului Gratuit: {_usage}/{_plan_limit} prezentari luna aceasta."
|
||||
f" Mai poti trimite {_remaining} luna aceasta."
|
||||
f" Treci pe Standard sau Pro, sau asteapta luna viitoare."
|
||||
)
|
||||
_prev_result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(_prev_result, str):
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=_err_msg))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, message=_err_msg, error=True, **_prev_result
|
||||
))
|
||||
|
||||
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
|
||||
if n_confirmat != n_total_ok:
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
|
||||
Reference in New Issue
Block a user