From 0a1df3112667d432349ceb0b69d0a11d2efa5bc5 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 2 Jul 2026 20:16:08 +0000 Subject: [PATCH] feat(5.20): US-010 badge mediu RAR in liste/preview/detaliu/jurnal + audit + ecou API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit labels.py: ETICHETE_ENV + eticheta_env(env)->(text,css). Productie afisata "PRODUCȚIE" (majuscule+diacritice) cu badge fill de atentie (--err), Testare outline discret muted — semnalizare risc L.142 (declaratie reala ireversibila). Clase .env-badge-prod / .env-badge-test in base.html; eticheta_env expus ca global Jinja. Badge de mediu per rand in _submissions, _coada implicit prin view, _preview_rand, _trimitere_detaliu, _jurnal. Statusbar (_status.html) aliniat la aceeasi conventie (Productie = atentie, nu verde) — inlocuieste culorile ad-hoc din US-011, toggle neatins. rar_env in exportul de audit (AUDIT_COLUMNS + _audit_rows) si ecou in GET /v1/prezentari(/{id}). _submission_row_view/_detaliu_ctx/fragment_submissions duc rar_env pana in template. tests/test_badge_rar_env.py: badge in lista, audit contine rar_env, GET ecou rar_env. test_statusbar_env: asertie aliniata la eticheta PRODUCȚIE. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 6 +- app/web/labels.py | 25 +++ app/web/routes.py | 14 +- app/web/templates/_jurnal.html | 5 + app/web/templates/_preview_rand.html | 5 + app/web/templates/_status.html | 14 +- app/web/templates/_submissions.html | 11 +- app/web/templates/_trimitere_detaliu.html | 6 +- app/web/templates/base.html | 8 + tests/test_badge_rar_env.py | 235 ++++++++++++++++++++++ tests/test_statusbar_env.py | 8 +- 11 files changed, 319 insertions(+), 18 deletions(-) create mode 100644 tests/test_badge_rar_env.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index dd1576e..fd44b7f 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -556,6 +556,8 @@ AUDIT_COLUMNS = [ "odometru_final", "prestatii", "rar_status_code", + # US-010 (PRD 5.20): mediul RAR tinta per trimitere. + "rar_env", "created_at", "updated_at", "purge_after", @@ -571,7 +573,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a scope_sql, scope_params = account_scope_clause(account_id) sql = ( "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, " - "created_at, updated_at, purge_after FROM submissions" + "rar_env, created_at, updated_at, purge_after FROM submissions" ) where = [scope_sql] params: list = list(scope_params) @@ -609,6 +611,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a "odometru_final": p.get("odometru_final") or "", "prestatii": codes, "rar_status_code": r["rar_status_code"] or "", + # US-010 (PRD 5.20): mediul RAR tinta — coloana audit. + "rar_env": r["rar_env"] or "", "created_at": r["created_at"], "updated_at": r["updated_at"], "purge_after": r["purge_after"] or "", diff --git a/app/web/labels.py b/app/web/labels.py index daf9efc..9c7c060 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -401,6 +401,31 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str: return "" +# --------------------------------------------------------------------------- +# Etichete mediu RAR (Test / Productie) — US-010 PRD 5.20 +# +# Conventia de culori (semantica risc L.142): +# prod = env-badge-prod (fill atentie, text alb) — declaratie REALA, ireversibila. +# test = env-badge-test (outline discret, --muted) — mediu de proba. +# --------------------------------------------------------------------------- + +ETICHETE_ENV: dict[str, tuple[str, str]] = { + "prod": ("PRODUCȚIE", "env-badge-prod"), + "test": ("Testare", "env-badge-test"), +} + + +def eticheta_env(env: object) -> tuple[str, str]: + """Returneaza (text, css_class) pentru mediul RAR. + + Fallback sigur: env necunoscut sau None -> ('Testare', 'env-badge-test'). + Nu arunca niciodata. + """ + if not env: + return ("Testare", "env-badge-test") + return ETICHETE_ENV.get(str(env), ("Testare", "env-badge-test")) + + # --------------------------------------------------------------------------- # Constante auxiliare (microcopy fix, fara logica) # --------------------------------------------------------------------------- diff --git a/app/web/routes.py b/app/web/routes.py index 6a6223b..7060c9f 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -32,6 +32,7 @@ from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, STARI_PREVIEW, + eticheta_env, eticheta_rar, eticheta_scurta, eticheta_stare, @@ -110,8 +111,9 @@ def _import_env_ctx(conn, account_id: int) -> dict: router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) -# Expune parse_erori in toate template-urile +# Expune parse_erori si eticheta_env in toate template-urile templates.env.globals["parse_erori"] = parse_erori +templates.env.globals["eticheta_env"] = eticheta_env _BLOCKED = ("error", "needs_data", "needs_mapping") @@ -528,6 +530,8 @@ def _jurnal_context( "has_more": has_more, "prev_page": page - 1 if page > 0 else None, "next_page": page + 1 if has_more else None, + # US-010 (PRD 5.20): mediul implicit al contului pentru badge de sectiune. + "env_default": rar_env_efectiv_cont(conn, account_id) or "test", } @@ -1042,6 +1046,8 @@ def _submission_row_view(r) -> dict: # randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru # stergere bulk; sent/sending/queued raman read-only (fara checkbox). "gestionabil": r["status"] in _GESTIONABILE_WEB, + # US-010 (PRD 5.20): mediul RAR tinta — badge in lista. + "rar_env": r["rar_env"] if "rar_env" in r.keys() else None, } @@ -1089,7 +1095,7 @@ def fragment_submissions( # FARA LIMIT — altfel paginile >8 ar disparea silentios. rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC", + f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC", params, ).fetchall() @@ -1130,7 +1136,7 @@ def fragment_submissions( rows_db = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " - f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC " + f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC " "LIMIT ? OFFSET ?", params + [_PAGE_SIZE, offset], ).fetchall() @@ -1352,6 +1358,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "editabil": row["status"] in _CORECTABILE, # error/needs_data/needs_mapping pot fi sterse / re-puse in coada "gestionabil": row["status"] in _GESTIONABILE_WEB, + # US-010 (PRD 5.20): mediul RAR tinta — badge in detaliu. + "rar_env": row["rar_env"] if "rar_env" in row.keys() else None, # mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, "nomenclator": nomenclator, diff --git a/app/web/templates/_jurnal.html b/app/web/templates/_jurnal.html index 5f2ccba..fbc5fba 100644 --- a/app/web/templates/_jurnal.html +++ b/app/web/templates/_jurnal.html @@ -10,6 +10,11 @@ {% else %} doar evenimentele contului tau {% endif %} + {# Badge mediu RAR activ (US-010 PRD 5.20) — mediul implicit al contului #} + {% if env_default | default('') %} + {% set _eb = eticheta_env(env_default) %} + {{ _eb[0] }} + {% endif %}
{{ row.stare_eticheta }} + {# Badge mediu RAR (US-010 PRD 5.20) — rar_env disponibil din contextul batch-ului. #} + {% if rar_env | default('') %} + {% set _eb = eticheta_env(rar_env) %} +
{{ _eb[0] }}
+ {% endif %} {{ row.prez.vehicul_nr }} diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index b77256f..0afd0be 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -127,19 +127,19 @@ La 0 medii: nu afisa (cont fara configuratie RAR). La 1 mediu: eticheta statica (fara toggle). La 2 medii: buton de comutare HTMX (toggle conditionat). + US-010: culori prin eticheta_env/clase .env-badge-* (consistenta cu badge-urile din liste). #} {% set _medii = medii_disponibile | default([]) %} {% set _env = env_default | default('prod') %} - {% set _env_label = "Testare" if _env == "test" else "Productie" %} + {% set _eb = eticheta_env(_env) %} {% if _medii | length >= 1 %}
- - Mediu RAR: {{ _env_label }} - + background:color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 10%, var(--card)); + border:1px solid color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 30%, transparent);"> + Mediu RAR: + {{ _eb[0] }} {% if _medii | length >= 2 %} {# Toggle: apare DOAR cand sunt cel putin 2 medii disponibile #} + title="Comuta la {{ 'PRODUCȚIE' if _env == 'test' else 'Testare' }}"> Comuta diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index e6e49c0..d43d734 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -136,9 +136,14 @@ {% endif %}
- {# Pill de stare — dreapta, flex:none #} - {{ r.stare_scurt }} + {# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) #} +
+ {{ r.stare_scurt }} + {% if r.rar_env %} + {% set _eb = eticheta_env(r.rar_env) %} + {{ _eb[0] }} + {% endif %} +
{% endfor %} diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html index dd3143a..0910437 100644 --- a/app/web/templates/_trimitere_detaliu.html +++ b/app/web/templates/_trimitere_detaliu.html @@ -7,10 +7,14 @@ {% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
- {# === Header — #id + pill + motiv uman === #} + {# === Header — #id + pill stare + badge mediu RAR (US-010 PRD 5.20) + motiv === #}

Detaliu trimitere #{{ id }}

{{ stare_text }} + {% if rar_env %} + {% set _eb = eticheta_env(rar_env) %} + {{ _eb[0] }} + {% endif %}
{% if motiv %}

{{ motiv }}

diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 17f2b7e..0f2a913 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -142,6 +142,14 @@ .s-ok{color:var(--ok);} .s-needs_review{color:var(--warn);} .s-already_sent,.s-duplicate_in_file{color:var(--muted);} + /* Badge mediu RAR (US-010 PRD 5.20) — semantica risc L.142. + Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila. + Testare: outline discret, ton --muted — mediu de proba, low-stakes. + NU se foloseste verde pentru Productie (ar semnala "sigur"). */ + .env-badge-prod { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px; font-weight:700; letter-spacing:.02em; + background:color-mix(in srgb, var(--err) 80%, var(--card)); color:#fff; border:1px solid transparent; } + .env-badge-test { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px; + background:transparent; color:var(--muted); border:1px solid var(--line); } .muted { color:var(--muted); } /* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */ .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; diff --git a/tests/test_badge_rar_env.py b/tests/test_badge_rar_env.py new file mode 100644 index 0000000..a480f8f --- /dev/null +++ b/tests/test_badge_rar_env.py @@ -0,0 +1,235 @@ +"""Teste US-010 (PRD 5.20): badge mediu RAR in liste/detaliu/audit/ecou API. + +Teste: + test_badge_in_lista -- /_fragments/submissions afiseaza PRODUCȚIE / Testare per rand + test_audit_contine_rar_env -- GET /v1/audit/export (CSV) contine coloana rar_env cu valoarea + test_get_ecou_rar_env -- GET /v1/prezentari si /v1/prezentari/{id} includ campul rar_env +""" + +from __future__ import annotations + +import csv +import io +import os +import re +import tempfile + +import pytest +from cryptography.fernet import Fernet +from starlette.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Fixture client izolat (DB temp + cheie Fernet; fara autentificare API-key) +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def client(monkeypatch): + """Client izolat cu DB temporara + cheie Fernet. AUTOPASS_REQUIRE_API_KEY=false.""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_badge.db")) + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false") + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + from app import crypto + + get_settings.cache_clear() + crypto.reset_cache() + from app.main import app + + with TestClient(app, follow_redirects=False) as c: + yield c + + get_settings.cache_clear() + crypto.reset_cache() + + +# --------------------------------------------------------------------------- +# Helpere — preluate din test_cont_medii.py (acelasi pattern izolat) +# --------------------------------------------------------------------------- + + +def _create_account_user( + name: str = "Service Badge SRL", + email: str = "badge@test.com", + password: str = "parolasecreta10", +): + """Creeaza cont + user. Returneaza (acct_id, user_id).""" + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + user_id = create_user(conn, acct_id, email, password) + return acct_id, user_id + finally: + conn.close() + + +def _login(client, email: str, password: str) -> None: + """Login real prin HTTP — seteaza cookie sesiune pe client.""" + resp = client.get("/login") + assert resp.status_code == 200 + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + if not m: + m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m, "csrf_token negasit pe /login" + csrf = m.group(1) + + resp = client.post( + "/login", + data={"email": email, "parola": password, "csrf_token": csrf}, + ) + assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}" + + +def _insert_submission(acct_id: int, rar_env: str, vin: str = "WVWZZZ1KZAW000123") -> int: + """Insereaza un submission direct in DB cu rar_env dat. Returneaza id-ul randului.""" + import json + from app.db import get_connection + from app.idempotency import build_key + + payload = { + "vin": vin, + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}], + } + ikey = build_key(acct_id, payload) + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions " + "(idempotency_key, account_id, status, payload_json, rar_env) " + "VALUES (?, ?, 'queued', ?, ?)", + (ikey, acct_id, json.dumps(payload), rar_env), + ) + row_id = cur.lastrowid + conn.commit() + return row_id + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Teste +# --------------------------------------------------------------------------- + + +def test_badge_in_lista(client): + """/_fragments/submissions afiseaza PRODUCȚIE pentru rar_env='prod' si Testare pt 'test'.""" + acct_id, _ = _create_account_user("Firma Badge 1", "b1@test.com") + _login(client, "b1@test.com", "parolasecreta10") + + # Submission cu mediu Productie + _insert_submission(acct_id, rar_env="prod", vin="WVWZZZ1KZAW000001") + # Submission cu mediu Testare + _insert_submission(acct_id, rar_env="test", vin="WVWZZZ1KZAW000002") + + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200, f"/_fragments/submissions: {resp.status_code}" + html = resp.text + + assert "PRODUC" in html.upper(), \ + f"Badge 'PRODUCȚIE' lipsa in lista submissions: {html[:800]}" + assert "testare" in html.lower(), \ + f"Badge 'Testare' lipsa in lista submissions: {html[:800]}" + + +def test_audit_contine_rar_env(client): + """GET /v1/audit/export?status=all (CSV) contine coloana rar_env cu valoarea corecta.""" + acct_id, _ = _create_account_user("Firma Badge 2", "b2@test.com") + + # Insereaza un submission cu rar_env='prod' si altul cu 'test' + _insert_submission(acct_id, rar_env="prod", vin="WVWZZZ1KZAW000003") + _insert_submission(acct_id, rar_env="test", vin="WVWZZZ1KZAW000004") + + # Exportul de audit functioneaza cu AUTOPASS_REQUIRE_API_KEY=false (cont id=1 implicit) + # Contul creat e acct_id; cont default e 1 (creat de schema). + # Inseram pe cont 1 explicit ca sa testam exportul fara cheie. + from app.db import get_connection + import json + conn = get_connection() + try: + payload = { + "vin": "WVWZZZ1KZAW000099", + "nr_inmatriculare": "B001AUD", + "data_prestatie": "2026-06-15", + "odometru_final": "10000", + "prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}], + } + conn.execute( + "INSERT INTO submissions " + "(idempotency_key, account_id, status, payload_json, rar_env) " + "VALUES ('audit-test-key', 1, 'sent', ?, 'prod')", + (json.dumps(payload),), + ) + conn.commit() + finally: + conn.close() + + resp = client.get("/v1/audit/export?status=all") + assert resp.status_code == 200, f"audit/export: {resp.status_code} {resp.text[:200]}" + + reader = csv.DictReader(io.StringIO(resp.text)) + assert "rar_env" in (reader.fieldnames or []), \ + f"Coloana 'rar_env' lipseste din CSV audit. Coloane: {reader.fieldnames}" + + rows = list(reader) + assert rows, "Exportul CSV este gol — nicio trimitere?" + + # Cel putin un rand are rar_env setat + env_values = {r["rar_env"] for r in rows if r.get("rar_env")} + assert env_values, f"Toate randurile au rar_env gol: {rows}" + assert "prod" in env_values, f"'prod' asteptat in valorile rar_env, gasit: {env_values}" + + +def test_get_ecou_rar_env(client): + """GET /v1/prezentari si /v1/prezentari/{id} includ campul rar_env in JSON.""" + # Insereaza direct pe cont 1 (default fara cheie cu AUTOPASS_REQUIRE_API_KEY=false) + import json + from app.db import get_connection + + conn = get_connection() + try: + payload = { + "vin": "WVWZZZ1KZAW000077", + "nr_inmatriculare": "B002ECO", + "data_prestatie": "2026-06-20", + "odometru_final": "55000", + "prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}], + } + cur = conn.execute( + "INSERT INTO submissions " + "(idempotency_key, account_id, status, payload_json, rar_env) " + "VALUES ('ecou-test-key-prod', 1, 'queued', ?, 'prod')", + (json.dumps(payload),), + ) + sub_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + # GET /v1/prezentari (lista) + resp_lista = client.get("/v1/prezentari") + assert resp_lista.status_code == 200, f"GET /v1/prezentari: {resp_lista.status_code}" + lista = resp_lista.json().get("submissions", []) + assert lista, "Lista de submission-uri este goala" + + # Fiecare rand din lista trebuie sa aiba campul rar_env + sub = next((s for s in lista if s.get("id") == sub_id), None) + assert sub is not None, f"Submission {sub_id} negasit in lista" + assert "rar_env" in sub, f"Campul 'rar_env' lipseste din raspunsul listei: {sub}" + assert sub["rar_env"] == "prod", f"rar_env asteptat 'prod', gasit {sub['rar_env']!r}" + + # GET /v1/prezentari/{id} (detaliu) + resp_det = client.get(f"/v1/prezentari/{sub_id}") + assert resp_det.status_code == 200, f"GET /v1/prezentari/{sub_id}: {resp_det.status_code}" + det = resp_det.json() + assert "rar_env" in det, f"Campul 'rar_env' lipseste din detaliul submission-ului: {det}" + assert det["rar_env"] == "prod", f"rar_env asteptat 'prod', gasit {det['rar_env']!r}" diff --git a/tests/test_statusbar_env.py b/tests/test_statusbar_env.py index a619e03..719ad52 100644 --- a/tests/test_statusbar_env.py +++ b/tests/test_statusbar_env.py @@ -133,8 +133,9 @@ def _get_csrf_din_status(client) -> str: def test_afiseaza_env_default(client): - """Cont cu UN singur mediu disponibil (prod) -> statusbar contine eticheta 'Productie', + """Cont cu UN singur mediu disponibil (prod) -> statusbar contine eticheta pentru Productie, fara niciun control de toggle/comutare. + US-010 (PRD 5.20): eticheta foloseste acum 'PRODUCȚIE' (diacritice + majuscule). """ acct_id, _ = _create_account_user("Firma Env1", "env1@test.com") _seteaza_mediu_disponibil(acct_id, "prod") @@ -144,8 +145,9 @@ def test_afiseaza_env_default(client): assert resp.status_code == 200, f"Status HTTP neasteptat: {resp.status_code}" html = resp.text - # Eticheta mediului trebuie prezenta - assert "Productie" in html, f"Eticheta 'Productie' absenta in statusbar: {html[:600]}" + # Eticheta mediului trebuie prezenta — "PRODUC" acopera atat 'PRODUCȚIE' (US-010) cat si + # formele anterioare 'Productie'/'PRODUCTIE' (anti-regresie). + assert "PRODUC" in html.upper(), f"Eticheta Productie absenta in statusbar: {html[:800]}" # Nu trebuie sa existe un control de comutare (toggle) la un singur mediu assert "toggle-env" not in html, \