From 19d8aaa7aa6757886fba12f051e83693b2033b61 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 29 Jun 2026 20:30:11 +0000 Subject: [PATCH] feat(5.20): US-004/005/006/009 ingestie+API+worker+import pe mediu RAR US-004: rezolva_rar_env (cerere>default cont>ancora globala) + MediuIndisponibil + cod RAR_MEDIU_INDISPONIBIL. US-005: camp rar_env pe POST /v1/prezentari + /valideaza (Literal), echo in SubmissionResult/ValidareResult/GET, build_key + INSERT env-aware. US-006: AccountSessions re-cheiat (account_id, rar_env); RarClient base_url per env; creds din slotul env; purge + recover_orphans scoped pe env (E1/1a, 1b/E6); claim_one propaga rar_env (1c/E8); keepalive pe ancora globala (M2). US-009: selector mediu la import (>=2 medii), eticheta la 1, banner la 0; commit seteaza rar_env pe submissions. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/import_router.py | 35 ++- app/api/v1/router.py | 76 +++++- app/errors.py | 7 + app/models.py | 9 + app/rar_client.py | 9 +- app/rar_env.py | 65 +++++ app/web/routes.py | 147 ++++++++-- app/web/templates/_preview_import.html | 10 + app/web/templates/_upload.html | 32 +++ app/worker/__main__.py | 166 +++++++----- tests/test_api_rar_target.py | 226 ++++++++++++++++ tests/test_creds_delivery.py | 4 +- tests/test_import_e2e.py | 4 +- tests/test_import_rar_env.py | 359 +++++++++++++++++++++++++ tests/test_rar_env_resolve.py | 96 +++++++ tests/test_t1_creds_durabile.py | 2 +- tests/test_worker_keepalive_rar.py | 4 +- tests/test_worker_observ.py | 4 +- tests/test_worker_rar_env.py | 326 ++++++++++++++++++++++ 19 files changed, 1451 insertions(+), 130 deletions(-) create mode 100644 tests/test_api_rar_target.py create mode 100644 tests/test_import_rar_env.py create mode 100644 tests/test_rar_env_resolve.py create mode 100644 tests/test_worker_rar_env.py diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index b55a810..61c566f 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -55,6 +55,7 @@ from ...mapping import ( resolve_prestatii, ) from ...validation import validate_prezentare +from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env router = APIRouter(prefix="/v1/import", tags=["import"]) @@ -260,10 +261,10 @@ def _resolve_row_for_preview( } -def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) -> str: +def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar_env: str = "test") -> str: """Construieste cheia de idempotenta pentru un rand rezolvat.""" canon = canonicalize_row(resolved) - return build_key(account_id, canon) + return build_key(account_id, canon, rar_env) # Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza @@ -767,6 +768,11 @@ def preview_import( valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) + # Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta + # la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit). + from ...config import get_settings as _get_settings_env + preview_env = rar_env_efectiv_cont(conn, account_id) or _get_settings_env().rar_env or "test" + # Recalculam coercion_flags din valorile stocate (nu sunt persistate separat): # detectie simpla de VIN numeric. coercion_flags_map: dict[int, list[str]] = {} @@ -822,7 +828,7 @@ def preview_import( key = None if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"): try: - key = _build_idempotency_key(account_id, resolved_info["resolved"]) + key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env) keys_for_lookup.append(key) if key not in key_to_index: key_to_index[key] = [] @@ -930,6 +936,7 @@ class CommitIn(BaseModel): description="Indecsi de rand needs_review bifate explicit de utilizator", ) confirmed_by: str | None = Field(None, description="Email/identifier utilizator (log atestare)") + rar_env: str | None = Field(None, description="Mediu RAR tinta ('test'|'prod'). None = default cont.") @router.post("/{import_id}/commit") @@ -1024,6 +1031,18 @@ def commit_import( if n_total_ok == 0: raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.") + # Rezolva mediul RAR tinta al lotului (US-009): cerut > default cont > ancora globala. + try: + env = rezolva_rar_env(conn, account_id, req.rar_env) + except ValueError as e: + raise HTTPException(status_code=422, detail={"error": "mediu_invalid", "message": str(e)}) + except MediuIndisponibil as e: + raise HTTPException(status_code=422, detail={ + "error": "mediu_indisponibil", + "message": str(e), + "disponibile": e.disponibile, + }) + # T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta). # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). from ...config import get_settings as _get_settings @@ -1172,8 +1191,8 @@ def commit_import( "odometru_final": canon["odometru_final"], }) - # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine) - key = build_key(account_id, canon) + # Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env) + key = build_key(account_id, canon, env) # Hash row pentru atestare (valori rezolvate) rows_for_hash.append(json.dumps({ @@ -1189,9 +1208,9 @@ def commit_import( # INSERT ON CONFLICT DO NOTHING (TOCTOU) cur = conn.execute( "INSERT OR IGNORE INTO submissions " - "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) " - "VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ")", - (key, acct, payload_json, import_id, row_index), + "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) " + "VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)", + (key, acct, payload_json, import_id, row_index, env), ) if cur.rowcount == 0: diff --git a/app/api/v1/router.py b/app/api/v1/router.py index a2f722b..dd1576e 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -24,6 +24,7 @@ from ...crypto import encrypt_creds from ...db import get_connection from ...errors import eroare as err_eroare from ...idempotency import build_key, canonicalize_row +from ...rar_env import MediuIndisponibil, rezolva_rar_env from ...mapping import ( _emite_text_rule_hits, account_or_default, @@ -122,7 +123,7 @@ def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> Submissio ) -def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult: +def _rezultat_respins(submission_id: int | None, cl: dict, rar_env: str = "test") -> SubmissionResult: """Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare. `erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate. @@ -131,6 +132,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult: return SubmissionResult( submission_id=submission_id, status="error", erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl), + rar_env=rar_env, ) @@ -168,6 +170,29 @@ def create_prezentari( text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) + # US-005: rezolva mediul RAR tinta (cerut > default cont > ancora globala). + # MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul). + try: + env = rezolva_rar_env(conn, acct, req.rar_env) + except MediuIndisponibil as e: + raise HTTPException( + status_code=422, + detail=err_eroare( + "RAR_MEDIU_INDISPONIBIL", + cauza=( + f"mediu cerut: {e.env}; disponibile: " + f"{', '.join(e.disponibile) or 'niciunul'}" + ), + ), + ) + except ValueError: + # Pydantic Literal prinde valorile invalide inainte sa ajunga aici; + # ramura e defensiva pentru apeluri directe fara model Pydantic. + raise HTTPException( + status_code=422, + detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"), + ) + # T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta). # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). from ...config import get_settings as _get_settings @@ -213,7 +238,7 @@ def create_prezentari( # build_key aplica account_or_default(account_id) inainte de hash: # None si 1 colapseaza la aceeasi cheie (canal API + canal import). canon = canonicalize_row(content) - key = build_key(account_id, canon) + key = build_key(account_id, canon, env) # Aplica normalizarea si in content (odometru canonicalizat inainte de validare) content.update({ "vin": canon["vin"], @@ -232,19 +257,20 @@ def create_prezentari( cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: nu reactivam; randul ramane 'error'. - results.append(_rezultat_respins(existing["id"], cl)) + results.append(_rezultat_respins(existing["id"], cl, rar_env=env)) continue cur = conn.execute( "UPDATE submissions SET status=?, payload_json=?, rar_error=?, " "rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, " "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, " - "updated_at=datetime('now') WHERE id=? AND status='error'", + "rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'", (cl["status"], json.dumps(cl["content"], ensure_ascii=False), - cl["rar_error"], creds_enc, existing["id"]), + cl["rar_error"], creds_enc, env, existing["id"]), ) if cur.rowcount == 1: # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc) # — ambele canale converg pe parola corectata. + # US-013: muta pe slot env dupa login (write-back conservator). if req.rar_credentials is not None: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", @@ -253,7 +279,7 @@ def create_prezentari( _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) # Raspuns onest si la reactivare: daca re-clasificarea cade pe # needs_data/needs_mapping, expune motivul (nu doar status). - results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True)) + results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env)) continue # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE # (rowcount==0) -> raspuns dedup pe starea CURENTA. @@ -267,6 +293,7 @@ def create_prezentari( status=existing["status"], id_prezentare=existing["id_prezentare"], deduped=True, + rar_env=env, ) ) continue @@ -276,17 +303,17 @@ def create_prezentari( cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). - results.append(_rezultat_respins(None, cl)) + results.append(_rezultat_respins(None, cl, rar_env=env)) continue cur = conn.execute( - "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " - "VALUES (?, ?, ?, ?, ?, ?)", - (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env), ) sub_id = int(cur.lastrowid) _emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) # Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv. - results.append(_rezultat_enqueue(sub_id, cl)) + results.append(_rezultat_enqueue(sub_id, cl, rar_env=env)) # Audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). @@ -332,6 +359,27 @@ def valideaza_prezentari( # Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text. text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) + + # US-005 (DX F5): rezolva env identic ca trimiterea reala si ecou-ieste in raspuns. + try: + env = rezolva_rar_env(conn, acct, req.rar_env) + except MediuIndisponibil as e: + raise HTTPException( + status_code=422, + detail=err_eroare( + "RAR_MEDIU_INDISPONIBIL", + cauza=( + f"mediu cerut: {e.env}; disponibile: " + f"{', '.join(e.disponibile) or 'niciunul'}" + ), + ), + ) + except ValueError: + raise HTTPException( + status_code=422, + detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"), + ) + for i, prez in enumerate(req.prezentari): content = prez.model_dump() res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) @@ -346,6 +394,7 @@ def valideaza_prezentari( index=i, valid=(res["status"] == "queued"), status_estimat=res["status"], + rar_env=env, erori=res["errors"], nemapate=nemapate, prestatii_rezolvate=res["resolved"], @@ -366,9 +415,10 @@ def list_prezentari( scope_sql, scope_params = account_scope_clause(account_id) # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca # sa derivam campurile afisabile prin helper-ul partajat, nu il expunem. + # rar_env inclus (US-005): badge mediu in lista. cols = ( "id, status, id_prezentare, rar_status_code, retry_count, " - "created_at, updated_at, payload_json" + "created_at, updated_at, payload_json, rar_env" ) if status: rows = conn.execute( @@ -403,6 +453,8 @@ _PREZENTARE_FIELDS = frozenset({ # erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza # "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API. "rar_error", + # US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API. + "rar_env", }) diff --git a/app/errors.py b/app/errors.py index a58d251..dabb64d 100644 --- a/app/errors.py +++ b/app/errors.py @@ -194,6 +194,13 @@ CATALOG: dict[str, dict[str, str]] = { " Contacteaza-ne pentru a face upgrade la planul Pro." ), }, + "RAR_MEDIU_INDISPONIBIL": { + "problema": "Mediul RAR cerut nu e disponibil", + "fix": ( + "Activeaza mediul si introdu credentialele RAR in tab-ul Cont." + " Mediile disponibile acum sunt in campul cauza." + ), + }, } diff --git a/app/models.py b/app/models.py index e6820fc..4045912 100644 --- a/app/models.py +++ b/app/models.py @@ -7,6 +7,8 @@ odometru) este in app.validation. from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field, field_validator, model_validator @@ -93,6 +95,8 @@ class PrezentareRequest(BaseModel): # False -> submission 'needs_mapping' (intra in editorul de mapare); # None -> se foloseste accounts.on_unmapped_error_default (implicit False). on_unmapped_error: bool | None = None + # Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT). + rar_env: Literal["test", "prod"] | None = None class SubmissionResult(BaseModel): @@ -105,6 +109,8 @@ class SubmissionResult(BaseModel): # RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza # semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). reactivated: bool = False + # Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie). + rar_env: str = "test" # Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune # motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes. # erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}]. @@ -126,6 +132,8 @@ class ValidarePrezentariRequest(BaseModel): rar_credentials: RarCredentials | None = None prezentari: list[PrezentareIn] = Field(..., min_length=1) on_unmapped_error: bool | None = None + # Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului. + rar_env: Literal["test", "prod"] | None = None class ValidareResult(BaseModel): @@ -134,6 +142,7 @@ class ValidareResult(BaseModel): index: int valid: bool status_estimat: str # "queued" | "needs_data" | "needs_mapping" + rar_env: str = "test" # mediul RAR tinta efectiv (ecou din rezolvare) erori: list[dict] = [] nemapate: list[dict] = [] prestatii_rezolvate: list[dict] = [] diff --git a/app/rar_client.py b/app/rar_client.py index 05053c5..a6bcf2a 100644 --- a/app/rar_client.py +++ b/app/rar_client.py @@ -44,6 +44,11 @@ class RarAuthError(RarError): """Login esuat (401 / credentiale invalide). NU se face retry.""" +def base_url_pentru_env(settings: "Settings", env: str) -> str: + """URL de baza al mediului RAR: 'prod' -> rar_base_url_prod, altfel rar_base_url_test.""" + return settings.rar_base_url_prod if env == "prod" else settings.rar_base_url_test + + class RarClient: """Client sincron httpx. Folosit din worker (proces separat). @@ -53,10 +58,10 @@ class RarClient: data = rar.post_prezentare(token, payload) """ - def __init__(self, settings: Settings | None = None): + def __init__(self, settings: Settings | None = None, *, base_url: str | None = None): self.settings = settings or get_settings() self._client = httpx.Client( - base_url=self.settings.rar_base_url, + base_url=base_url if base_url is not None else self.settings.rar_base_url, timeout=self.settings.http_timeout_s, headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403 ) diff --git a/app/rar_env.py b/app/rar_env.py index df20c52..43ba425 100644 --- a/app/rar_env.py +++ b/app/rar_env.py @@ -89,3 +89,68 @@ def medii_disponibile_cont(conn: sqlite3.Connection, account_id: int) -> list[st def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | None: return rar_env_efectiv(load_account_env(conn, account_id)) + + +# --------------------------------------------------------------------------- # +# Exceptie si rezolvator de mediu tinta (US-004, dependent de US-002) # +# --------------------------------------------------------------------------- # + +class MediuIndisponibil(Exception): + """Mediul RAR cerut e valid dar nu e disponibil pentru contul dat. + + Atribute + -------- + env: mediul cerut (ex. 'test') + disponibile: lista mediilor disponibile pentru cont in momentul erorii + """ + + def __init__(self, env: str, disponibile: list[str]) -> None: + self.env = env + self.disponibile = disponibile + super().__init__( + f"mediu indisponibil: {env!r} (disponibile: {disponibile!r})" + ) + + +def rezolva_rar_env( + conn: sqlite3.Connection, + account_id: int, + cerut: str | None = None, +) -> str: + """Determina mediul RAR tinta pentru un submission la ingestie. + + Precedenta stricta (de la cea mai mare la cea mai mica): + 1. `cerut` explicit si disponibil -> intoarce `cerut`. + 2. `cerut` explicit dar indisponibil -> ridica MediuIndisponibil. + 3. `cerut` invalid (nu in VALID_ENVS) -> ridica ValueError (fara fallback silentios). + 4. `cerut` None -> incearca rar_env_efectiv_cont (default-ul contului). + 5. Daca contul nu are niciun mediu disponibil (rar_env_efectiv_cont == None) + -> cade pe ancora globala get_settings().rar_env, normalizata la VALID_ENVS. + Acest fallback e intentionat (PRD 5.20 §2 Non-Goals): AUTOPASS_RAR_ENV ramane + ancora de migrare si fallback pentru actiuni fara cont (keepalive, canal API cu + creds efemere pe conturi nou-create fara medii configurate). + + Ridica + ------ + ValueError -- `cerut` nu e in VALID_ENVS + MediuIndisponibil -- `cerut` e valid dar nu e disponibil pentru cont + """ + if cerut is not None: + if cerut not in VALID_ENVS: + raise ValueError(f"mediu invalid: {cerut!r}") + disp = medii_disponibile_cont(conn, account_id) + if cerut not in disp: + raise MediuIndisponibil(cerut, disp) + return cerut + + # cerut e None: incearca default-ul contului + efectiv = rar_env_efectiv_cont(conn, account_id) + if efectiv is not None: + return efectiv + + # Ancora globala: 0 medii disponibile pe cont -> fallback la AUTOPASS_RAR_ENV. + from .config import get_settings + global_env = get_settings().rar_env + if global_env in VALID_ENVS: + return global_env + return "test" # rar_env invalid in config -> cel mai sigur default diff --git a/app/web/routes.py b/app/web/routes.py index 53dcf5c..7bacf7f 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -89,10 +89,24 @@ from ..mapping import ( text_rules_overlap, ) from ..shared_store import record_human_validation +from ..rar_env import MediuIndisponibil, medii_disponibile_cont, rar_env_efectiv_cont, rezolva_rar_env # Campuri canonice cu eticheta umana pentru dropdown mapare coloane _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()] + +def _import_env_ctx(conn, account_id: int) -> dict: + """Contextul de mediu RAR pentru paginile de import (US-009, PRD 5.20). + + Intoarce {'medii': list[str], 'env_default': str} pentru template-ul _upload.html. + Un mediu e disponibil = activat SI are credentiale. La 0 medii template afiseaza + un banner non-blocant; la 1 eticheta statica; la >=2 selector. + """ + medii = medii_disponibile_cont(conn, account_id) + env_default = rar_env_efectiv_cont(conn, account_id) or "prod" + return {"medii": medii, "env_default": env_default} + + router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) # Expune parse_erori in toate template-urile @@ -738,8 +752,13 @@ def fragment_acasa(request: Request) -> HTMLResponse: @router.get("/_fragments/import", response_class=HTMLResponse) def fragment_import(request: Request) -> HTMLResponse: """Fragment HTMX pentru tab-ul Import — include zona de upload.""" - require_login(request) - return templates.TemplateResponse("_upload.html", _ctx(request)) + account_id = require_login(request) + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() + return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx)) @router.get("/_fragments/coada", response_class=HTMLResponse) @@ -2684,13 +2703,20 @@ def _web_compute_preview( conn, import_id: int, account_id: int, + rar_env: str | None = None, ) -> dict[str, Any] | str: """Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare. Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature din import_router. Nu repeta logica de rezolvare — only orchestrare. + + `rar_env`: mediul RAR ales de operator; None = default efectiv al contului + (sau ancora globala daca contul nu are medii configurate). Folosit la calculul + cheii de idempotenta la preview — trebuie sa coincida cu env-ul de la commit. """ acct = account_or_default(account_id) + # Mediul folosit la calculul cheii de idempotenta (preview == commit). + preview_env = rar_env or rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" batch = conn.execute( "SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?", @@ -2796,7 +2822,7 @@ def _web_compute_preview( key: str | None = None if info["resolved_status"] in ("ok", "needs_review", "needs_data"): try: - key = _build_idempotency_key(account_id, info["resolved"]) + key = _build_idempotency_key(account_id, info["resolved"], preview_env) keys_for_lookup.append(key) key_to_indices.setdefault(key, []).append(i) except Exception: @@ -2901,6 +2927,7 @@ async def web_upload_import( file: UploadFile = File(...), sheet_name: str | None = Form(None), csrf_token: str | None = Form(None), + rar_env: str | None = Form(None), ) -> HTMLResponse: """Upload fisier xlsx/csv → staging; intoarce fragment HTML. @@ -2919,31 +2946,64 @@ async def web_upload_import( try: parsed = parse_file(data, filename, sheet_name=sheet_name) except MultipleSheets as ms: - return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names)) + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() + return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names, **env_ctx)) except FileTooLarge as e: eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)) + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() return templates.TemplateResponse("_upload.html", _ctx( - request, error=str(e), eroare_upload=eroare_upload + request, error=str(e), eroare_upload=eroare_upload, **env_ctx )) except HeaderError as e: eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}") + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() return templates.TemplateResponse("_upload.html", _ctx( - request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload + request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload, **env_ctx )) except UnicodeDecodeError as e: eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}") + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() return templates.TemplateResponse("_upload.html", _ctx( - request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload + request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload, **env_ctx )) except Exception as e: eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}") + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() return templates.TemplateResponse("_upload.html", _ctx( - request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload + request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload, **env_ctx )) conn = get_connection() try: sig = _signature(parsed.columns) + env_ctx = _import_env_ctx(conn, account_id) + + # Rezolva mediul RAR ales — cerut din form sau default cont (fallback ancora globala). + # La 0 medii: rezolva_rar_env cade pe ancora globala (rar_env config), non-blocant. + try: + upload_env = rezolva_rar_env(conn, account_id, rar_env or None) + except (ValueError, MediuIndisponibil): + upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" # Stagingul in DB (tranzactie explicita) conn.execute("BEGIN IMMEDIATE") @@ -2978,18 +3038,17 @@ async def web_upload_import( if existing: # Mapare retinuta → computa preview imediat - result = _web_compute_preview(conn, batch_id_int, account_id) + result = _web_compute_preview(conn, batch_id_int, account_id, rar_env=upload_env) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", { - "request": request, - "error": result, - "csrf_token": get_csrf_token(request), - }) + return templates.TemplateResponse("_upload.html", _ctx( + request, error=result, **env_ctx + )) return templates.TemplateResponse("_preview_import.html", { "request": request, "import_id": batch_id_int, "message": "Mapare retinuta aplicata automat.", "csrf_token": get_csrf_token(request), + "rar_env": upload_env, **result, }) @@ -3012,6 +3071,7 @@ async def web_upload_import( "canonical_fields": _CANONICAL_FIELDS, "format_data": None, "csrf_token": get_csrf_token(request), + "rar_env": upload_env, }) finally: conn.close() @@ -3129,8 +3189,9 @@ async def web_save_mapare_coloane( (import_id, acct), ).fetchone() if not batch: + env_ctx = _import_env_ctx(conn, account_id) return templates.TemplateResponse("_upload.html", _ctx( - request, error="Batch de import inexistent sau expirat." + request, error="Batch de import inexistent sau expirat.", **env_ctx )) # Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele @@ -3148,12 +3209,20 @@ async def web_save_mapare_coloane( (acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val), ) + # Mediu RAR transmis din form-ul de mapare (daca exista) sau default cont + form_rar_env = str(form.get("rar_env") or "").strip() or None + try: + mapare_env = rezolva_rar_env(conn, account_id, form_rar_env) + except (ValueError, MediuIndisponibil): + mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + # Computa preview - result = _web_compute_preview(conn, import_id, account_id) + result = _web_compute_preview(conn, import_id, account_id, rar_env=mapare_env) if isinstance(result, str): - return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + env_ctx = _import_env_ctx(conn, account_id) + return templates.TemplateResponse("_upload.html", _ctx(request, error=result, **env_ctx)) return templates.TemplateResponse("_preview_import.html", _ctx( - request, import_id=import_id, **result + request, import_id=import_id, rar_env=mapare_env, **result )) finally: conn.close() @@ -3163,22 +3232,31 @@ async def web_save_mapare_coloane( def web_preview_import( request: Request, import_id: int, + rar_env: str | None = None, ) -> HTMLResponse: """Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa.""" account_id = require_login(request) conn = get_connection() try: - result = _web_compute_preview(conn, import_id, account_id) + # Rezolva mediul pentru preview (din query param sau default cont) + try: + preview_env = rezolva_rar_env(conn, account_id, rar_env) + except (ValueError, MediuIndisponibil): + preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + result = _web_compute_preview(conn, import_id, account_id, rar_env=preview_env) if isinstance(result, str): + env_ctx = _import_env_ctx(conn, account_id) return templates.TemplateResponse("_upload.html", { "request": request, "error": result, "csrf_token": get_csrf_token(request), + **env_ctx, }) return templates.TemplateResponse("_preview_import.html", { "request": request, "import_id": import_id, "csrf_token": get_csrf_token(request), + "rar_env": preview_env, **result, }) finally: @@ -3601,10 +3679,13 @@ async def web_mapare_operatii( @router.get("/_import/reset", response_class=HTMLResponse) def web_import_reset(request: Request) -> HTMLResponse: """Reseteaza sectiunea de import la starea initiala (drop zone gol).""" - return templates.TemplateResponse("_upload.html", { - "request": request, - "csrf_token": get_csrf_token(request), - }) + account_id = require_login(request) + conn = get_connection() + try: + env_ctx = _import_env_ctx(conn, account_id) + finally: + conn.close() + return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx)) @router.post("/_import/{import_id}/confirma", response_class=HTMLResponse) @@ -3631,6 +3712,9 @@ async def web_confirma_import( except (ValueError, TypeError): n_confirmat = 0 + # Mediu RAR din form (selectat in preview); None = default cont (fallback ancora globala) + rar_env_cerut = str(form.get("rar_env") or "").strip() or None + # US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul # de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8). # Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok' @@ -3798,6 +3882,12 @@ async def web_confirma_import( valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) + # Rezolva mediul RAR tinta al lotului (US-009): form > default cont > ancora globala. + try: + env = rezolva_rar_env(conn, account_id, rar_env_cerut) + except (ValueError, MediuIndisponibil): + env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + # Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU) enqueued: list[dict] = [] toctou: list[int] = [] @@ -3857,7 +3947,7 @@ async def web_confirma_import( "odometru_final": canon["odometru_final"], }) - key = build_key(account_id, canon) + key = build_key(account_id, canon, env) rows_for_hash.append(json.dumps({ "row_index": row_index, @@ -3872,9 +3962,9 @@ async def web_confirma_import( cur = conn.execute( "INSERT OR IGNORE INTO submissions " - "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) " - "VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'))", - (key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index), + "(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) " + "VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'), ?)", + (key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index, env), ) if cur.rowcount == 0: toctou.append(row_index) @@ -3922,8 +4012,9 @@ async def web_confirma_import( status_ctx = _build_status_ctx(request, conn, account_id, oob=True) # Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus). + env_ctx_success = _import_env_ctx(conn, account_id) upload_html = templates.get_template("_upload.html").render( - _ctx(request, are_trimiteri=True, message=succes_msg) + _ctx(request, are_trimiteri=True, message=succes_msg, **env_ctx_success) ) coada_html = templates.get_template("_coada.html").render(acasa_ctx) status_html = templates.get_template("_status.html").render(status_ctx) diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index 492d351..1327757 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -14,6 +14,14 @@ Preview — {{ filename or ("import #" ~ import_id) }} + {# Badge mediu RAR (US-009): vizibil intotdeauna in preview (claritate tinta) #} + {% if rar_env %} + + {{ "PRODUCTIE" if rar_env == "prod" else "Testare" }} + + {% endif %} {{ total }} randuri @@ -178,6 +186,8 @@ hx-target="#import-section" hx-swap="outerHTML"> + {# Mediu RAR ales la upload — propagar la commit (US-009) #} +