From 4a2afc68bfda61ef6e17d39ea08131bdbbee1958 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 25 Jun 2026 21:44:24 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20curatare=20agresiva=20comentarii=20?= =?UTF-8?q?=E2=80=94=20scoatere=20referinte=20US/PRD=20din=20cod=20si=20te?= =?UTF-8?q?mplate-uri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminat zgomotul de trasabilitate (US-xxx, PRD x.x, Rn, OV-x, Tn, decizii/naratiune istorica) din 41 fisiere app/ + template-uri. Pastrate comentariile care documenteaza invarianti si logica ne-evidenta (idempotenta/hash, reconciliere anti-duplicat, RAR 500 esec definitiv, creds per cont, WAF User-Agent, 422 fara echo de parola, scope NULL->1), curatate doar de tokeni. Verificare: pentru cele 27 module .py curatate, structura de cod (tokeni non-comentariu/ non-string) e IDENTICA fata de HEAD -> doar comentarii/docstring-uri schimbate. Singura schimbare de cod e in tests/test_web_responsive.py (scos 3 assert pe markeri US-006/007/008, inlocuite de asertiunile structurale alaturate). 0 tokeni US/PRD reziduali in app/. Regresie: 896 passed, 1 deselected. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/import_router.py | 111 ++++----- app/api/v1/integrare_router.py | 2 +- app/api/v1/router.py | 89 ++++--- app/auth.py | 4 +- app/config.py | 40 ++- app/db.py | 14 +- app/email.py | 2 +- app/errors.py | 2 +- app/idempotency.py | 39 ++- app/import_parse.py | 26 +- app/main.py | 19 +- app/mapping.py | 78 +++--- app/models.py | 23 +- app/observ.py | 23 +- app/payload_view.py | 10 +- app/security.py | 2 +- app/submissions_admin.py | 20 +- app/users.py | 8 +- app/web/admin_routes.py | 8 +- app/web/auth_routes.py | 2 +- app/web/csrf.py | 2 +- app/web/labels.py | 17 +- app/web/middleware.py | 4 +- app/web/ratelimit.py | 4 +- app/web/routes.py | 282 +++++++++++----------- app/web/session.py | 8 +- app/web/templates/_acasa.html | 8 +- app/web/templates/_coada.html | 10 +- app/web/templates/_eroare.html | 2 +- app/web/templates/_jurnal.html | 4 +- app/web/templates/_macros.html | 14 +- app/web/templates/_mapari.html | 15 +- app/web/templates/_nomenclator.html | 6 +- app/web/templates/_preview_import.html | 14 +- app/web/templates/_preview_rand.html | 8 +- app/web/templates/_submissions.html | 23 +- app/web/templates/_trimitere_detaliu.html | 50 ++-- app/web/templates/_upload.html | 6 +- app/web/templates/admin.html | 2 +- app/web/templates/base.html | 125 +++++----- app/web/templates/dashboard.html | 5 - app/worker/__main__.py | 61 +++-- tests/test_web_responsive.py | 4 - 43 files changed, 547 insertions(+), 649 deletions(-) diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 7d50059..b509803 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -8,14 +8,13 @@ Endpointuri: POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review) -Reguli cheie (plan §3.1-3.4, §12): - - Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany. - - Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1. - - OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker. - - Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. - - Issue 5a: import_rows.raw_json CRIPTAT Fernet. - - Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY). - - T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare. +Reguli cheie: + - Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany. + - already_sent lookup BATCH (IN chunk ~900), nu N+1. + - duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker. + - TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. + - import_rows.raw_json CRIPTAT Fernet. + - Drift semnatura coloane -> NU aplica orb, cere re-confirmare. """ from __future__ import annotations @@ -60,7 +59,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"]) # Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999) _IN_CHUNK = 900 -# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4) +# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane _CANONICAL_SYNONYMS: dict[str, list[str]] = { "vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"], "nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"], @@ -93,7 +92,7 @@ def _fuzzy_suggest_column( ) -> list[dict]: """Sugereaza campuri canonice pentru o coloana din fisier. - Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4). + Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio. Intoarce [{camp_canonic, score}] sortat descrescator. """ from rapidfuzz import fuzz, process @@ -140,10 +139,10 @@ def _resolve_row_for_preview( errors: lista erori validare flags: motive needs_review - `override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL - peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea - unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara - sa atinga `raw_json`/idempotency. + `override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile + mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori + sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga + `raw_json`/idempotency. """ # Aplica maparea de coloane mapped: dict[str, Any] = {} @@ -151,7 +150,7 @@ def _resolve_row_for_preview( if col_fisier in raw_row and camp_canonic: mapped[camp_canonic] = raw_row[col_fisier] - # Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag + # Detectie coloane cu formule — nu blocheaza, dar adauga flag formula_flag: list[str] = [] for col_fisier, camp_canonic in json_mapare.items(): if col_fisier in formula_columns: @@ -186,7 +185,7 @@ def _resolve_row_for_preview( denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val) mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}] - # Canonicalizare (T9): normalizeaza VIN/nr/odometru + # Canonicalizare: normalizeaza VIN/nr/odometru canon = canonicalize_row(mapped) mapped.update({ "vin": canon["vin"], @@ -194,7 +193,7 @@ def _resolve_row_for_preview( "odometru_final": canon["odometru_final"], }) - # Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate + + # Override editat in preview — aplicat ULTIMUL, peste valorile mapate + # canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override). if override: mapped.update(override) @@ -230,7 +229,7 @@ def _resolve_row_for_preview( "flags": all_flags, } - # auto_send gate (T6/OV-1) + # auto_send gate if has_no_auto_send(resolved, mapping_meta): return { "resolved_status": "needs_mapping", @@ -261,8 +260,8 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) -> return build_key(account_id, canon) -# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza -# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6. +# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza +# aici (raman in panoul de mapare). EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final") @@ -350,7 +349,7 @@ def apply_row_override( def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]: - """Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5). + """Cauta cheile de idempotenta in submissions (batch, nu N+1). Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite. """ @@ -371,7 +370,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di "id_prezentare": r["id_prezentare"], "created_at": r["created_at"], } - # Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None) + # Dual-lookup pentru chei legacy (chei vechi cu account_id=None) legacy_keys_needed = [k for k in chunk if k not in found] if legacy_keys_needed: lph = ",".join("?" * len(legacy_keys_needed)) @@ -403,8 +402,7 @@ async def upload_import( """Upload fisier xlsx/csv -> staging in import_batches/import_rows. Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}. - PII (raw_json) criptat Fernet la rest (Issue 5a). - Scrieri bulk in tranzactie explicita (Issue 6). + PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita. """ acct = account_or_default(account_id) data = await file.read() @@ -468,7 +466,7 @@ async def upload_import( try: sig = _signature(parsed.columns) - # Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany + # Tranzactie explicita BEGIN IMMEDIATE + executemany conn.execute("BEGIN IMMEDIATE") try: # Insert import_batches @@ -482,7 +480,7 @@ async def upload_import( # Insert import_rows bulk (executemany) cu PII criptat row_params = [] for i, row_dict in enumerate(parsed.rows): - raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a) + raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet row_params.append((batch_id, i, raw_json_enc, "pending", None)) conn.executemany( @@ -506,11 +504,8 @@ async def upload_import( # Sample rows (primele 3, fara PII) sample = parsed.rows[:3] - # Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns) - # in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK, - # ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct) - # Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat - # SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch. + # Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul + # o recalculeaza din raw_json deja stocat. conn.execute( "UPDATE import_batches SET ok=?, needs_review=? WHERE id=?", (0, len(parsed.coercion_flags), batch_id), @@ -532,7 +527,7 @@ async def upload_import( result["column_mapping"] = json.loads(existing_mapping["json_mapare"]) result["format_data"] = existing_mapping["format_data"] else: - # Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match) + # Sugestii fuzzy per coloana suggestions: dict[str, list[dict]] = {} for col in parsed.columns: sugg = _fuzzy_suggest_column(col, limit=3) @@ -676,7 +671,7 @@ def save_column_mapping( # --------------------------------------------------------------------------- # -# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) # +# GET /v1/import/{id}/preview — 6 stari per rand # # --------------------------------------------------------------------------- # @router.get("/{import_id}/preview") @@ -686,8 +681,8 @@ def preview_import( ) -> dict: """Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file. - Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch - collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker). + Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch + collision (EXCLUSIV aici, NU in reconcile.py/worker). """ acct = account_or_default(account_id) conn = get_connection() @@ -708,7 +703,7 @@ def preview_import( if not raw_rows_db: return {"rows": [], "summary": {}} - # Decripteaza si reconstruieste randurile + override-urile editate (3.6) + # Decripteaza si reconstruieste randurile + override-urile editate rows: list[dict] = [] overrides: list[dict] = [] for r in raw_rows_db: @@ -747,22 +742,18 @@ def preview_import( json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) format_data = mapping_row["format_data"] - # Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data) + # Incarca maparea de operatii o singura data mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} - # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + # Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) - # Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate - # Nota: import_rows stocheaza raw_json dupa coercion (din parse_file) - # Recalculam flags din valorile stocate (coercion_flags nu e stocat separat) - # Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float + # Recalculam coercion_flags din valorile stocate (nu sunt persistate separat): + # detectie simpla de VIN numeric. coercion_flags_map: dict[int, list[str]] = {} - # Detectam din valorile stocate for i, row_dict in enumerate(rows): flags = [] - # Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar for col_f, camp_c in json_mapare.items(): if camp_c == "vin": vin_val = row_dict.get(col_f) @@ -830,11 +821,11 @@ def preview_import( "idempotency_key": key, }) - # Already_sent: batch lookup (Eng#5 — nu N+1) + # Already_sent: batch lookup (nu N+1) unique_keys = list(set(keys_for_lookup)) already_sent_map = _already_sent_lookup(conn, account_id, unique_keys) - # Duplicate_in_file (OV-3): detectie coliziuni intra-batch + # Duplicate_in_file: detectie coliziuni intra-batch. # Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate key_to_indices: dict[str, list[int]] = {} for row in preview_rows: @@ -857,7 +848,7 @@ def preview_import( row["already_sent_info"] = sent_info continue - # Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier + # Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier indices_with_same_key = key_to_indices.get(k, []) if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"): others = [idx for idx in indices_with_same_key if idx != row["row_index"]] @@ -911,7 +902,7 @@ def preview_import( # --------------------------------------------------------------------------- # -# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) # +# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare # # --------------------------------------------------------------------------- # class CommitIn(BaseModel): @@ -929,9 +920,9 @@ def commit_import( req: CommitIn, account_id: int = Depends(resolve_account_id), ) -> dict: - """Gate HARD confirmare + enqueue randuri ok + log atestare (T5+T12). + """Gate HARD confirmare + enqueue randuri ok + log atestare. - TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. + TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING. Randuri colidante = reclasificate already_sent in rezultatul commit-ului. rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada. """ @@ -981,7 +972,7 @@ def commit_import( elif r["resolved_status"] == "needs_review": review_indices.add(r["row_index"]) - # needs_review bifate explicit (Voce#1 — atestare pe valori) + # needs_review bifate explicit (atestare pe valori) confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices] for idx in confirmed_review: # Gaseste randul needs_review si il adauga la ok_rows @@ -1040,7 +1031,7 @@ def commit_import( # Incarca maparea de operatii mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} - # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + # Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) @@ -1049,10 +1040,9 @@ def commit_import( toctou_collisions: list[int] = [] rows_for_hash: list[str] = [] - # Enqueue in tranzactie explicita (Issue 6) + # Enqueue in tranzactie explicita conn.execute("BEGIN IMMEDIATE") try: - # purge_after pentru submissions noi (T16) purge_after_sql = "datetime('now', '+90 days')" for ok_row in ok_rows: @@ -1100,7 +1090,7 @@ def commit_import( "odometru_final": canon["odometru_final"], }) - # Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver. + # Override editat in preview — aplicat ULTIMUL, ca in resolver. override = ok_row.get("override") or {} if override: mapped.update(override) @@ -1127,7 +1117,7 @@ def commit_import( payload_json = json.dumps(mapped, ensure_ascii=False) - # INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1) + # 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) " @@ -1140,7 +1130,6 @@ def commit_import( toctou_collisions.append(row_index) else: sub_id = cur.lastrowid - # US-010: telemetrie pentru itemii rezolvati prin regula text. _emite_text_rule_hits(conn, acct, int(sub_id), resolved) enqueued.append({ "submission_id": sub_id, @@ -1155,7 +1144,7 @@ def commit_import( n_enqueued = len(enqueued) - # Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada + # Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada rows_hash = hashlib.sha256( json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8") ).hexdigest() if rows_for_hash else "" @@ -1185,7 +1174,7 @@ def commit_import( # --------------------------------------------------------------------------- # -# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) # +# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview # # --------------------------------------------------------------------------- # class RandEditIn(BaseModel): @@ -1205,7 +1194,7 @@ def editeaza_rand( req: RandEditIn, account_id: int = Depends(resolve_account_id), ) -> dict: - """Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6). + """Persista editarea unui rand de preview (mutatie pura). NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul prin `_resolve_row_for_preview` cu override aplicat ultimul. @@ -1225,7 +1214,7 @@ def editeaza_rand( # --------------------------------------------------------------------------- # -# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) # +# GET /v1/import/{id}/export-failed — CSV randuri esuate # # --------------------------------------------------------------------------- # _EXPORT_FAILED_COLUMNS = [ diff --git a/app/api/v1/integrare_router.py b/app/api/v1/integrare_router.py index fa3c55f..b329c1c 100644 --- a/app/api/v1/integrare_router.py +++ b/app/api/v1/integrare_router.py @@ -1,4 +1,4 @@ -"""Router integrare US-001 — endpoint-uri de integrare externe. +"""Router integrare — endpoint-uri de integrare externe. Endpointuri: GET /v1/ping — readiness check per cont (autentificat sau dev fallback) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index bf59b26..1166101 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,12 +1,10 @@ -"""API v1 — suprafata gateway (schelet). +"""API v1 — suprafata gateway. -Endpointuri din plan.md sect. 4. In schelet: +Endpointuri: - POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE). - GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada. - GET /v1/nomenclator: cache local. - GET /v1/mapari: listare mapari cont. -Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in -middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc. """ from __future__ import annotations @@ -79,7 +77,7 @@ def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, tex def _erori_nemapate(unmapped: list[dict]) -> list[dict]: - """Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest.""" + """Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT).""" return [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")} for u in unmapped @@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]: def _motiv_clasificare(cl: dict) -> str | None: - """Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7). + """Rezumat uman pe o linie pentru un rezultat de clasificare. None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut (needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping). @@ -107,7 +105,7 @@ def _motiv_clasificare(cl: dict) -> str | None: def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult: - """SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7). + """SubmissionResult onest dintr-un rezultat de clasificare. Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman) pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None. @@ -141,42 +139,40 @@ def create_prezentari( ) -> PrezentariResponse: """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. - Validarea de continut (T3, app.validation) ruleaza inainte de enqueue: - esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv - (plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape). + Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU + resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat -> + 422 din Pydantic (validare de shape). account_id vine din cheia API (resolve_account_id): cont real cu cheie, implicit id=1 in dev fara cheie, 401 fara cheie valida in prod. - Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi - pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`). """ acct = account_or_default(account_id) # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la # primul login reusit pentru cont (worker le sterge atunci). Zero-storage at - # rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc, + # rest — niciodata in clar in DB/loguri. Optional: cand lipsesc, # creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului. creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None conn = get_connection() results: list[SubmissionResult] = [] try: - # T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi). + # load_mapping_meta include auto_send per op (gate pentru coduri noi). mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). valid_codes = load_nomenclator_codes(conn) or None - # Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2). + # Reguli text incarcate o data per cerere (seam partajat cu dry-run). text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) for prez in req.prezentari: content = prez.model_dump() - # T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). + # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # 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) - # Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis) + # Aplica normalizarea si in content (odometru canonicalizat inainte de validare) content.update({ "vin": canon["vin"], "nr_inmatriculare": canon["nr_inmatriculare"], @@ -187,7 +183,7 @@ def create_prezentari( (key,), ).fetchone() if existing: - # US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit + # Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. if existing["status"] == "error": @@ -205,17 +201,16 @@ def create_prezentari( cl["rar_error"], creds_enc, existing["id"]), ) if cur.rowcount == 1: - # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc, - # decizie #17) — ambele canale converg pe parola corectata. + # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc) + # — ambele canale converg pe parola corectata. if req.rar_credentials is not None: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (encrypt_creds(req.rar_credentials.model_dump()), acct), ) - # US-010: telemetrie pentru itemii rezolvati prin regula text. _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) - # Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea - # cade pe needs_data/needs_mapping, expune motivul (nu doar status). + # 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)) continue # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE @@ -234,7 +229,7 @@ def create_prezentari( ) continue - # Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea + # Helper pur partajat cu dry-run: reproduce EXACT clasificarea # (canonicalize + mapare op->cod + validare + auto_send gate). cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: @@ -247,13 +242,12 @@ def create_prezentari( (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) sub_id = int(cur.lastrowid) - # US-010: telemetrie pentru itemii rezolvati prin regula text. _emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) - # Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv. + # Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv. results.append(_rezultat_enqueue(sub_id, cl)) - # US-004: audit cerere API per cont. Doar metadate (count + distributie status), - # NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL). + # Audit cerere API per cont. Doar metadate (count + distributie status), + # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). dist: dict[str, int] = {} for r in results: if r.reactivated: @@ -284,7 +278,7 @@ def valideaza_prezentari( Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi - payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2). + payload + aceeasi mapare de cont. rar_credentials ignorat complet. """ acct = account_or_default(account_id) conn = get_connection() @@ -301,7 +295,7 @@ def valideaza_prezentari( res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if res["blocked_error"]: res = {**res, "status": "error"} - # US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT + # Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT nemapate = [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")} for u in res["unmapped"] @@ -329,7 +323,7 @@ def list_prezentari( try: 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 (US-003, DRY), nu il expunem. + # sa derivam campurile afisabile prin helper-ul partajat, nu il expunem. cols = ( "id, status, id_prezentare, rar_status_code, retry_count, " "created_at, updated_at, payload_json" @@ -357,13 +351,13 @@ def list_prezentari( conn.close() -# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4). -# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since. +# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita. +# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since. _PREZENTARE_FIELDS = frozenset({ "id", "status", "id_prezentare", "rar_status_code", "retry_count", "next_attempt_at", "created_at", "updated_at", "account_id", "batch_id", "row_index", "purge_after", - # T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si + # rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si # 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", @@ -383,7 +377,7 @@ def get_prezentare( [submission_id] + scope_params, ).fetchone() if not row: - # B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont + # Acelasi mesaj indiferent daca randul exista dar apartine altui cont # sau nu exista deloc — nu confirmam existenta. raise HTTPException(status_code=404, detail="submission inexistent") row_dict = dict(row) @@ -397,11 +391,11 @@ def delete_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: - """Sterge o trimitere blocata a contului cheii API (US-010). + """Sterge o trimitere blocata a contului cheii API. Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat - INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi - mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare). + INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj); + own-account `sent`/`sending` -> 409 (conflict de stare). """ conn = get_connection() try: @@ -424,10 +418,10 @@ def repune_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: - """Re-pune in coada o trimitere blocata a contului cheii API (US-010). + """Re-pune in coada o trimitere blocata a contului cheii API. - `error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de - scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending). + `error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE + (404 cross-account/inexistent, 409 sent/sending). """ conn = get_connection() try: @@ -478,8 +472,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a """Randuri audit filtrate pe cont + data(updated_at) in [from, to]. account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu - account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in - schelet; b64_image NU intra in CSV. + account_id IS NULL apartin contului 1. b64_image NU intra in CSV. """ scope_sql, scope_params = account_scope_clause(account_id) sql = ( @@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a "submission_id": r["id"], "status": r["status"], "id_prezentare": r["id_prezentare"] or "", - # NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu. + # NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu. "account_id": account_or_default(r["account_id"]), "vin": p.get("vin") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "", @@ -539,7 +532,7 @@ def audit_export( pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); `status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana - `purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta. + `purge_after`. b64_image nu se exporta. """ conn = get_connection() try: @@ -568,7 +561,7 @@ def get_mapari( """Maparile operatie->cod ale contului curent. Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul - efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400. + efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400. """ if account_id is not None and account_id != key_account: raise HTTPException( @@ -635,7 +628,7 @@ def create_mapare( class RarCredsIn(BaseModel): - """Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc.""" + """Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc.""" email: str = Field(..., min_length=1) password: str = Field(..., min_length=1, repr=False) @@ -646,7 +639,7 @@ def set_rar_creds( req: RarCredsIn, account_id: int = Depends(resolve_account_id), ) -> dict: - """Seteaza creds RAR durabile per-cont (D4/T1). + """Seteaza creds RAR durabile per-cont. Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker). diff --git a/app/auth.py b/app/auth.py index e98c289..436e632 100644 --- a/app/auth.py +++ b/app/auth.py @@ -112,7 +112,7 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None: - """Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia. + """Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia. Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time (observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte. @@ -142,7 +142,7 @@ def resolve_account_id( - cheie invalida (prezenta) -> 401 (mereu, indiferent de flag) - fara cheie + flag off -> cont implicit (id=1), back-compat - fara cheie + flag on -> 401 - Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004). + Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie. """ settings = get_settings() plaintext = _extract_key(x_api_key, authorization) diff --git a/app/config.py b/app/config.py index b8306f6..dddf3ab 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,8 @@ """Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite. -NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO -(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul - din settings.xml DOAR pentru dev local / probe pe mediul de test. +NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO. +Helper-ul `load_test_credentials` citeste blocul din settings.xml DOAR +pentru dev local / probe pe mediul de test. """ from __future__ import annotations @@ -22,22 +22,21 @@ class Settings(BaseSettings): # --- Bază de date --- db_path: Path = ROOT / "data" / "autopass.db" - # --- Observabilitate / jurnal aplicatie (PRD 5.6) --- + # --- Observabilitate / jurnal aplicatie --- # Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul # e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL. log_level: str = "INFO" - # Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5). log_retention_days: int = 90 - # Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie, decizie §5). + # Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie). # Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe. log_dir: Path = ROOT / ".run" log_file_max_bytes: int = 5_000_000 log_file_backup_count: int = 5 # Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z - # ale `sent` — un blocat n-are valoare de audit (decizie §5). + # ale `sent` — un blocat n-are valoare de audit. blocked_retention_days: int = 30 - # --- Securitate (CORE) --- + # --- Securitate --- # Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> # cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA # dar invalida da 401 indiferent de flag. @@ -49,29 +48,28 @@ class Settings(BaseSettings): # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" creds_key: str | None = None - # --- Sesiuni web (US-002, PRD 3.3) --- + # --- Sesiuni web --- # Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok; # in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza # la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))" session_secret: str | None = None # True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login; - # CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5), + # CSRF enforce. Pentru dev rapid pe contul implicit id=1, # seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false. web_auth_required: bool = True - # True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4). + # True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag. # False (dev): cookie fara Secure, functioneaza pe HTTP. session_https_only: bool = False - # --- Notificare email admin la signup (US-012, PRD 3.3b) --- - # Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP); - # follow-up cand exista SMTP real configurat in .env. + # --- Notificare email admin la signup --- + # Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP). smtp_host: str | None = None smtp_port: int = 587 smtp_user: str | None = None smtp_password: str | None = None smtp_from: str | None = None - # --- Rate-limit signup + login (US-009, PRD 3.3 C5) --- + # --- Rate-limit signup + login --- # Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua). signup_rate_max: int = 5 signup_rate_window_s: int = 3600 @@ -83,25 +81,23 @@ class Settings(BaseSettings): rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass" - # WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi - # docs/api-rar-contract.md). Toate apelurile httpx il trimit. + # WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit. http_user_agent: str = "Mozilla/5.0" http_timeout_s: float = 30.0 # --- Worker --- worker_poll_interval_s: float = 5.0 worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat - # In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit - # pentru proba end-to-end. Reconcilierea/retry-ul complet = T2. + # Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru + # proba end-to-end. worker_send_enabled: bool = False # Dev: foloseste creds din settings.xml pt login worker. In productie - # creds vin per-cerere de la ROAAUTO (T2) — lasa False. + # creds vin per-cerere de la ROAAUTO — lasa False. worker_use_test_creds: bool = False - # T2 — recuperare orfane + retry/backoff: worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST) worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max) worker_retry_max_s: int = 300 - worker_max_retries: int = 8 # peste atat -> error + banner (pana persistenta) + worker_max_retries: int = 8 # peste atat -> error + banner @property def rar_base_url(self) -> str: diff --git a/app/db.py b/app/db.py index c852cd7..f4d41ff 100644 --- a/app/db.py +++ b/app/db.py @@ -61,13 +61,13 @@ def _migrate(conn: sqlite3.Connection) -> None: if "rar_creds_enc" not in acc_cols: conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") if "active" not in acc_cols: - # Conturi existente raman active (default 1). Lifecycle consumat de 3.3. + # Conturi existente raman active (default 1). conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1") acc_cols.add("active") if "status" not in acc_cols: - # Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b). - # Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`: - # active=0 -> 'pending'. Invariant: active=1 <=> status='active'. + # Stare de ciclu de viata. Default 'active' (trece CHECK pe randurile existente), + # apoi derivam din `active`: active=0 -> 'pending'. + # Invariant: active=1 <=> status='active'. conn.execute( "ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' " "CHECK (status IN ('pending','active','blocked','archived','deleted'))" @@ -97,9 +97,7 @@ def _migrate(conn: sqlite3.Connection) -> None: if "email_verified" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0") - # Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in - # preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create - # inainte de 3.6 nu au coloana. + # Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet. irows_tbl = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'" ).fetchone() @@ -151,7 +149,7 @@ def queue_depth(conn: sqlite3.Connection) -> int: return int(row["n"]) if row else 0 -# --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) --- +# --- Jurnal de aplicatie (app_events) --- def insert_app_event( conn: sqlite3.Connection, diff --git a/app/email.py b/app/email.py index 821596a..cf5bd4d 100644 --- a/app/email.py +++ b/app/email.py @@ -1,4 +1,4 @@ -"""Helper notificare email admin la signup (US-012, PRD 3.3b). +"""Helper notificare email admin la signup. Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar). Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata. diff --git a/app/errors.py b/app/errors.py index 1ffbf98..f74b15a 100644 --- a/app/errors.py +++ b/app/errors.py @@ -1,4 +1,4 @@ -"""Catalog central de erori AutoPass (PRD 5.4). +"""Catalog central de erori AutoPass. Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix), cu un helper care construieste obiectul de eroare pe 3 niveluri: diff --git a/app/idempotency.py b/app/idempotency.py index 9e47438..073018c 100644 --- a/app/idempotency.py +++ b/app/idempotency.py @@ -1,24 +1,18 @@ """Cheie de idempotenta = hash de continut canonic. -RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra -(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii. +RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra. +Hash stabil peste o reprezentare canonica a prezentarii. -Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice -partajate intre canalul API si canalul import. +canonicalize_row + build_key sunt helpere publice partajate intre canalul API si +canalul import: - canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE - de validare (§3.4bis) si INAINTE de cheie. + de validare si INAINTE de cheie. - build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie). + Altfel acelasi rand logic din canale diferite (account_id None pe canalul API, + 1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat. -OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API -fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None. -Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent -rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la -account_or_default inainte de hash. - -Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de -build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia -noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie -se poate face recompute-keys o singura data. +Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la +already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data. """ from __future__ import annotations @@ -46,10 +40,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]: - data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser). - prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii). """ - # VIN vin = (raw.get("vin") or "").strip().upper() - - # Nr. inmatriculare nr = (raw.get("nr_inmatriculare") or "").strip().upper() # Odometru: strip ".0" Excel float coercion @@ -82,8 +73,8 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]: def build_key(account_id: int | None, canon: dict[str, Any]) -> str: """SHA-256 partajat canal-API + canal-import. - Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la - aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori. + Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi + cheie => acelasi rand logic din canale diferite nu se trimite de doua ori. """ # Import local ca sa evitam import circular (mapping importa din idempotency via validator) from .mapping import account_or_default @@ -106,8 +97,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str: Wrapper backward-compat peste canonicalize_row + build_key. Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei). - NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie - (via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt + NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via + account_or_default in build_key). Randuri vechi cu cheie-None nu sunt acoperite automat — dual-lookup sau recompute-keys la migrare productie. """ canon = canonicalize_row(prezentare) @@ -117,8 +108,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str: def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str: """Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize). - Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi - (dinainte de T9). Nu folosi pentru randuri noi. + Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi. + Nu folosi pentru randuri noi. """ canonic = { "account_id": account_id, diff --git a/app/import_parse.py b/app/import_parse.py index 58389c9..3351fd4 100644 --- a/app/import_parse.py +++ b/app/import_parse.py @@ -1,6 +1,6 @@ -"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2, U1). +"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2). -Arhitectura 2-treceri (Issue 2, consens cross-model): +Arhitectura 2-treceri: Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet. Trecerea 2 — normal-mode: header + merged cells + body. Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate. @@ -29,7 +29,7 @@ from typing import Any, NamedTuple MAX_ROWS = 5_000 MAX_BYTES = 5 * 1024 * 1024 # 5 MB -# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate (Issue 3) +# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate FORMULA_NONE_RATE = 0.6 # Coloane cheie pentru detectia footer-ului (trim structural) @@ -82,7 +82,7 @@ class ParsedFile(NamedTuple): columns: list[str] # Numele coloanelor detectate (din header) rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta} coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]} - formula_columns: list[str] # Coloane cu rata None ridicata (Issue 3) + formula_columns: list[str] # Coloane cu rata None ridicata date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"} @@ -230,13 +230,13 @@ def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile: # Trim footer: elimina randuri trailing unde coloanele cheie sunt goale raw_rows = _trim_footer(raw_rows, col_names) - # Detectie coloane cu formule (rata None, Issue 3) + # Detectie coloane cu formule (rata None ridicata) formula_columns = _detect_formula_columns(col_values, len(raw_rows)) - # Detectie format data la nivel de coloana (T10/OV-8) + # Detectie format data la nivel de coloana date_col_format = _detect_date_formats(col_values, col_names) - # Coercion + flags needs_review (T3) + # Coercion + flags needs_review coercion_flags: dict[int, list[str]] = {} processed_rows: list[dict[str, Any]] = [] for i, row_dict in enumerate(raw_rows): @@ -289,7 +289,7 @@ def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[ # --------------------------------------------------------------------------- # -# Detectie coloane formule (Issue 3) # +# Detectie coloane formule # # --------------------------------------------------------------------------- # def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]: @@ -306,7 +306,7 @@ def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> li # --------------------------------------------------------------------------- # -# Dezambiguizare data la nivel de coloana (T10 / OV-8) # +# Dezambiguizare data la nivel de coloana # # --------------------------------------------------------------------------- # def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]: @@ -344,7 +344,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) result[col_name] = "mixed" continue - # Toate string — detectie format la nivel de coloana (OV-8) + # Toate string — detectie format la nivel de coloana fmt = _infer_date_format_from_column(str_vals) result[col_name] = fmt @@ -354,7 +354,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) def _infer_date_format_from_column(str_vals: list[str]) -> str: """Detecteaza formatul datei dintr-o lista de valori string. - Logica OV-8: daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first. + Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first. Daca toti zi <= 12 -> ambiguu. """ dd_first_evidence = False @@ -421,7 +421,7 @@ def _split_date(s: str) -> list[str] | None: # --------------------------------------------------------------------------- # -# Coercion per rand (T3) # +# Coercion per rand # # --------------------------------------------------------------------------- # def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]: @@ -682,7 +682,7 @@ def parse_csv(data: bytes) -> ParsedFile: def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile: """Parseaza un fisier XLSX. - Arhitectura 2-treceri (Issue 2): + Arhitectura 2-treceri: 1. read_only=True: dim-check + detectie multi-sheet 2. normal-mode: header + merged cells + body diff --git a/app/main.py b/app/main.py index 358141c..c4ce7a2 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,7 @@ """Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics. -Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici -(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos"). - -Pornire dev: uvicorn app.main:app --reload +Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici: +un worker mort nu trebuie sa lase containerul "sanatos". """ from __future__ import annotations @@ -44,7 +42,7 @@ from .web.session import AdminRequired, LoginRequired async def lifespan(app: FastAPI): install_log_redaction() # Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar, - # in loc de 500 brut la primul POST /v1/prezentari (cazul reprodus din VFP). + # in loc de 500 brut la primul POST /v1/prezentari. validate_creds_key() init_db() yield @@ -61,7 +59,7 @@ app.add_middleware( https_only=settings.session_https_only, same_site="strict", ) -# US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza +# request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza # OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile, # inclusiv 401/404/422/500 produse mai in interior. app.add_middleware(RequestIDMiddleware) @@ -97,13 +95,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: - """Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut. + """Orice excepție neprinsa -> 500 STRUCTURAT din catalog in loc de 500 brut. Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message) + `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text). - Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException) - raman neatinse — acesta prinde doar ce nu are handler dedicat. """ request_id = getattr(request.state, "request_id", None) or request_id_var.get() try: @@ -144,9 +140,8 @@ app.include_router(admin_router) def healthz() -> dict: """Sanatate: worker viu + ultimul login RAR reusit + adancime coada. - Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort - -> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii; - orchestratorul decide pe campul `worker_alive`. + Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul + `worker_alive`. """ settings = get_settings() conn = get_connection() diff --git a/app/mapping.py b/app/mapping.py index 2ee0036..6a135af 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -1,7 +1,7 @@ """Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor. -Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni -fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service` +Contract (varianta hibrida): un item de prestatie poate veni +fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service` (cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping` (nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza @@ -87,7 +87,7 @@ def suggest_codes( ] -# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010). +# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text. # Forma: "text_rule:". Payload-harmless — # RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect. COD_SURSA_TEXT_RULE_PREFIX = "text_rule:" @@ -111,10 +111,10 @@ def _rezolva_din_reguli_text( `valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid -> (None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`. - Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel + Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie), nu cel normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e - falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare - umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil). + falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu + trimis automat la RAR (blast radius substring + FINALIZATA ireversibil). """ if not text_rules: return None, None, None @@ -136,7 +136,7 @@ def _rezolva_din_reguli_text( def text_rule_hits(resolved: list[dict] | None) -> list[dict]: - """Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010). + """Extrage din itemii rezolvati cei care au primit cod dintr-o regula text. Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa` incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il @@ -154,7 +154,7 @@ def text_rule_hits(resolved: list[dict] | None) -> list[dict]: def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]: - """Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant). + """Reguli text existente care se SUPRAPUN cu `pattern` (avertisment neblocant). Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei reguli existente SAU invers (oricare directie). Pur, determinist, fara DB. @@ -192,9 +192,9 @@ def resolve_prestatii( - item fara cod, fara mapare si fara regula text -> ramane nemapat. - item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in - fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri - din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da - HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw. + fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana + COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL + la RAR (terminal) -> nu-l trimitem niciodata raw. Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service` in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista @@ -217,8 +217,8 @@ def resolve_prestatii( unmapped: list[dict] = [] for item in prestatii or []: it = dict(item) - # Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de - # hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare. + # Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe + # regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare. # Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra # un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit. it.pop("cod_sursa", None) @@ -246,11 +246,11 @@ def resolve_prestatii( ) if cod_regula is not None: it["cod_prestatie"] = cod_regula - # Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu - # pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie). + # Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul + # sursa. Payload-harmless (RAR citeste doar cod_prestatie). it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}" - # Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar - # TINE randul pentru verificare umana (has_no_auto_send -> True). + # Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul + # pentru verificare umana (has_no_auto_send -> True). if not auto_send_regula: it["regula_fara_autosend"] = True else: @@ -273,7 +273,7 @@ def account_or_default(account_id: int | None) -> int: def account_scope_clause(account_id: int) -> tuple[str, list]: """Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable. - Aplica regula: NULL apartine contului 1 (legacy/OV-2). + Aplica regula: NULL apartine contului 1 (legacy). Foloseste DOAR pe submissions (account_id NULLABLE). NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu. """ @@ -356,7 +356,7 @@ def load_mapping(conn, account_id: int | None) -> dict[str, str]: def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]: """{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont. - T6/OV-1: varianta extinsa care include si flagul auto_send per operatie. + Varianta extinsa care include si flagul auto_send per operatie. """ acct = account_or_default(account_id) rows = conn.execute( @@ -379,7 +379,7 @@ def classify_prezentare( """Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte. Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru - a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2). + a garanta acelasi verdict — invariantul de corectitudine dry-run. Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}. "content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate). @@ -434,10 +434,10 @@ def classify_prezentare( def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool: """Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text). - T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat. - PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu - auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri - randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada". + Un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat. + La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de + `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane + needs_mapping (review manual) pana cand operatorul activeaza „In coada". Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate. """ for item in resolved: @@ -457,7 +457,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]: footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern. account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL - apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python. + apartine contului 1). Filtrarea in SQL, nu post-hoc in Python. """ nomenclator = load_nomenclator(conn) if account_id is not None: @@ -577,9 +577,9 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None: def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None: """Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text. - US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event - inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — - fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import. + Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite + exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara + PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import. """ hits = text_rule_hits(resolved) if not hits: @@ -604,25 +604,25 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) """Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare. Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate -> - ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu + ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}. - T6/OV-1: auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane - 'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent. + auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping' + cu motiv "review manual"); previne FINALIZATA eronat permanent. - T7: batch_id != None -> scope la seria comitata (NU cross-batch). - batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus). + batch_id != None -> scope la seria comitata (NU cross-batch). + batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus). """ acct = account_or_default(account_id) mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None - # T2: incarca regulile text O DATA, inainte de bucla pe randuri. + # Incarca regulile text O DATA, inainte de bucla pe randuri. text_rules = load_text_rules(conn, acct) if batch_id is not None: - # T7: scope la batch-ul specificat (import commit explicit). + # Scope la batch-ul specificat (import commit explicit). # NU atinge randuri din alte batches sau din feed API. rows = conn.execute( "SELECT id, payload_json FROM submissions " @@ -631,8 +631,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) ).fetchall() else: # POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL). - # T7/R1 INCHIS: salvarea unei mapari NU re-queues randuri din batches de import - # (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit. + # Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch / + # cross-feed). Batches de import sunt re-rezolvate doar la commit explicit. rows = conn.execute( "SELECT id, payload_json FROM submissions " "WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL", @@ -649,7 +649,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) content["prestatii"] = resolved payload_json = json.dumps(content, ensure_ascii=False) - # US-010: telemetrie pentru itemii rezolvati prin regula text. + # Telemetrie pentru itemii rezolvati prin regula text. _emite_text_rule_hits(conn, acct, r["id"], resolved) if unmapped: @@ -660,7 +660,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None) stats["still_blocked"] += 1 continue - # T6/OV-1: verifica auto_send inainte de re-queuing + # Verifica auto_send inainte de re-queuing. if has_no_auto_send(resolved, mapping_meta): conn.execute( "UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?", diff --git a/app/models.py b/app/models.py index 8315450..e6820fc 100644 --- a/app/models.py +++ b/app/models.py @@ -1,9 +1,8 @@ """Modele Pydantic pentru suprafata API. -ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare, -dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial -obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este -**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3. +Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de +continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine +odometru) este in app.validation. """ from __future__ import annotations @@ -20,7 +19,7 @@ class RarCredentials(BaseModel): class PrestatieItem(BaseModel): - """O operatie de declarat. Contract hibrid (decis 2026-06-15): + """O operatie de declarat. Contract hibrid: ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE `cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le @@ -55,7 +54,7 @@ class PrezentareIn(BaseModel): Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in app.validation.validate_prezentare si NU resping cererea — marcheaza - `needs_data` (plan.md sect. 3). + `needs_data`. """ vin: str @@ -102,12 +101,12 @@ class SubmissionResult(BaseModel): status: str id_prezentare: int | None = None deduped: bool = False # True daca idempotency a intors un submission existent - # US-012 (decizie /autoplan #19): camp ADITIV. True cand un rand `error` cu aceeasi - # cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. - # `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). + # Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost + # RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza + # semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). reactivated: bool = False - # Raspuns ONEST pentru randuri blocate (PRD 5.7): orice status != 'queued' isi - # expune motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes. + # 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}]. # Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat). # nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire. @@ -122,7 +121,7 @@ class PrezentariResponse(BaseModel): class ValidarePrezentariRequest(BaseModel): - """Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2).""" + """Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue.""" rar_credentials: RarCredentials | None = None prezentari: list[PrezentareIn] = Field(..., min_length=1) diff --git a/app/observ.py b/app/observ.py index a7fd666..e1eda67 100644 --- a/app/observ.py +++ b/app/observ.py @@ -1,12 +1,11 @@ -"""Logger structurat central (PRD 5.6 US-003). +"""Logger structurat central. Singurul punct prin care se emit evenimente de aplicatie: garanteaza format, redactare si dublul canal (app_events in DB + log text rotativ) consistente si -imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU -doboara cererea/worker-ul. +imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul. Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii` -(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007). +(creds/token mascate integral, VIN/nr partial) inainte de persistare. """ from __future__ import annotations @@ -22,8 +21,8 @@ from .config import get_settings from .db import get_connection, insert_app_event from .security import redact_pii, scrub_text -# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil -# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii. +# request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in +# handlerul de erori si aici, fara a polua semnaturile de functii. request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( "request_id", default=None ) @@ -31,7 +30,7 @@ request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( _LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50} # Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default); -# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces). +# worker-ul cheama set_source('worker') la pornire (fisier per-proces). _DEFAULT_SOURCE = "api" _loggers: dict[str, logging.Logger] = {} @@ -46,9 +45,9 @@ def set_source(sursa: str) -> None: def _text_logger(sursa: str) -> logging.Logger: """Logger cu RotatingFileHandler pe fisier per-proces (app-.log). - Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy. - Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger - nou, fara a acumula handlere duplicate pe acelasi fisier. + Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache + include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a + acumula handlere duplicate pe acelasi fisier. """ settings = get_settings() path = settings.log_dir / f"app-{sursa}.log" @@ -94,10 +93,10 @@ def log_event( ) -> None: """Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat). - - `tip`: text liber documentat (lista extensibila, decizie §5). + - `tip`: text liber documentat (lista extensibila). - `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat. - `context`: metadate (submission_id, count, status...) — NU payload PII integral. - - `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4); + - `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL); None -> deschide/inchide o conexiune proprie. Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul). """ diff --git a/app/payload_view.py b/app/payload_view.py index 403f720..6550e1f 100644 --- a/app/payload_view.py +++ b/app/payload_view.py @@ -1,4 +1,4 @@ -"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5). +"""Extragere payload submission -> campuri afisabile. Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API (`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie @@ -115,10 +115,10 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: # cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0" cod_rar = _clean_cod_rar(item.get("cod_prestatie")) - # US-002: operatia de service originala (codul intern + denumire venita prin API/import), + # Operatia de service originala (codul intern + denumire venita prin API/import), # distincta de operatia RAR mapata (cod_rar). - # Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—". - # Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`. + # Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—". + # Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`. # Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—". op_service_cod = _clean_str(item.get("cod_op_service")) # op_service_denumire e relevant doar cand exista un cod de operatie de service; @@ -134,7 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: "odometru": odo or EMPTY, "cod": cod or EMPTY, "cod_rar": cod_rar or EMPTY, - # US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus + # Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus "op_service_cod": op_service_cod, "op_service_denumire": op_service_denumire, } diff --git a/app/security.py b/app/security.py index c20e756..3b40d64 100644 --- a/app/security.py +++ b/app/security.py @@ -40,7 +40,7 @@ SENSITIVE_KEYS = frozenset( # Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR -# partial (ultimele 4), niciodata integral (PRD 5.6 US-007, L.142/GDPR). +# partial (ultimele 4), niciodata integral (L.142/GDPR). PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"}) diff --git a/app/submissions_admin.py b/app/submissions_admin.py index 01d253c..8ed7734 100644 --- a/app/submissions_admin.py +++ b/app/submissions_admin.py @@ -1,19 +1,19 @@ -"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009). +"""Lifecycle trimiteri blocate: sterge / re-pune in coada. -Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel -permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate — -stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge -logica de trimitere a worker-ului. +Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si +nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri +ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere +a worker-ului. -Invariante (decizii §2 + /autoplan #20): +Invariante: - Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE. - Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU - al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri + al altui cont -> SubmissionNotFound (404, nu confirmam existenta). Doar pe randuri proprii in stare gresita -> SubmissionStateConflict (409). - - Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`. + - Ambele emit eveniment in jurnal: `submission_sters` / `submission_repus`. -Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011). +Functii cu `conn` (persistenta). """ from __future__ import annotations @@ -80,7 +80,7 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict: `error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping` daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si - CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013). + CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare). Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce {"submission_id", "status_anterior", "status_nou"}. """ diff --git a/app/users.py b/app/users.py index 467bf36..89a44f5 100644 --- a/app/users.py +++ b/app/users.py @@ -1,8 +1,8 @@ -"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3. +"""Helper-e utilizatori web (email + parola scrypt). Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru -migrare cost viitoare (C9). +migrare cost viitoare. """ from __future__ import annotations @@ -98,7 +98,7 @@ def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True) """Seteaza/sterge rolul admin pe toti userii contului dat. Ridica ValueError daca contul nu exista. - Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010). + Daca contul exista dar nu are useri, e no-op silentios. """ acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() if not acct: @@ -119,7 +119,7 @@ def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool: def list_admin_emails(conn: sqlite3.Connection) -> list[str]: - """Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012).""" + """Returneaza emailurile tuturor userilor cu is_admin=1.""" rows = conn.execute( "SELECT email FROM users WHERE is_admin=1" ).fetchall() diff --git a/app/web/admin_routes.py b/app/web/admin_routes.py index de6eb68..cd70f13 100644 --- a/app/web/admin_routes.py +++ b/app/web/admin_routes.py @@ -1,4 +1,4 @@ -"""Panou admin web /admin. US-011 PRD 3.3b. +"""Panou admin web /admin. Rute: GET /admin — listeaza conturi in asteptare + active (require_admin) @@ -49,7 +49,7 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co emails = _emails_by_account(conn) for acct in accounts: acct["email"] = emails.get(acct["id"]) - # Grupare pe STARE (5.5), 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. pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1] active = [a for a in accounts if a["status"] == "active" and a["id"] != 1] @@ -79,7 +79,7 @@ async def admin_get(request: Request): def _apply_lifecycle(conn, ids: list[int], action: str) -> None: - """Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate + """Aplica un verb de ciclu de viata pe o lista de conturi. Conturile protejate (id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul). `action`: activate | block | archive | delete.""" for aid in ids: @@ -97,7 +97,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None: def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str): - """Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG. + """Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG. Evita 4 handlere copy-paste care difera doar prin verb.""" require_admin(request) verify_csrf(request, csrf_token) diff --git a/app/web/auth_routes.py b/app/web/auth_routes.py index 45a9236..ade6f5c 100644 --- a/app/web/auth_routes.py +++ b/app/web/auth_routes.py @@ -1,4 +1,4 @@ -"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3.""" +"""Rute autentificare web: /signup, /login, /logout.""" from __future__ import annotations diff --git a/app/web/csrf.py b/app/web/csrf.py index d397aef..aa19bd1 100644 --- a/app/web/csrf.py +++ b/app/web/csrf.py @@ -1,4 +1,4 @@ -"""CSRF token per-sesiune + validare. US-009 PRD 3.3. +"""CSRF token per-sesiune + validare. Contract pentru rutele POST web: - Formulare HTML includ: diff --git a/app/web/labels.py b/app/web/labels.py index 388a033..b1eb2be 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -1,9 +1,6 @@ -""" -labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4). +"""Traducere stari tehnice in text uman + clasa CSS. Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri. - -Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001. """ import json @@ -59,7 +56,7 @@ STARI_SUBMISSION: dict[str, Eticheta] = { # --------------------------------------------------------------------------- -# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006) +# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri # Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care # despacheteaza 3 elemente). eticheta_stare ramane neatinsa. # --------------------------------------------------------------------------- @@ -155,7 +152,7 @@ def eticheta_rar(stare: str) -> Eticheta: # --------------------------------------------------------------------------- -# Format data RAR (US-001, PRD 3.5) +# Format data RAR # --------------------------------------------------------------------------- def format_data_rar(raw: object) -> str: @@ -181,7 +178,7 @@ def format_data_rar(raw: object) -> str: # --------------------------------------------------------------------------- -# Motiv uman din rar_error (US-004, PRD 3.5) +# Motiv uman din rar_error # --------------------------------------------------------------------------- def motiv_uman(status: str, rar_error: object) -> str: @@ -231,7 +228,7 @@ def motiv_uman(status: str, rar_error: object) -> str: # --------------------------------------------------------------------------- -# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4) +# parse_erori — transforma rar_error in lista 3-niveluri # --------------------------------------------------------------------------- def parse_erori(rar_error: object) -> list[dict]: @@ -275,7 +272,7 @@ def parse_erori(rar_error: object) -> list[dict]: "cauza": e.get("cauza") or e.get("message") or "", "fix": e.get("fix") or "", "field": e.get("field"), - # Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal (US-001/R1). + # Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal. "cod": e.get("cod"), }) else: @@ -305,7 +302,7 @@ def parse_erori(rar_error: object) -> list[dict]: "cauza": data.get("cauza") or "", "fix": data.get("fix") or "", "field": data.get("field"), - # Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal (US-001/R1). + # Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal. "cod": data.get("cod"), }] # Dict vechi: unmapped diff --git a/app/web/middleware.py b/app/web/middleware.py index 366160f..b7c9035 100644 --- a/app/web/middleware.py +++ b/app/web/middleware.py @@ -1,8 +1,8 @@ -"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002). +"""Middleware HTTP: request_id per cerere. Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar) -in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile. +in handlerul de erori si in `log_event` — fara a polua semnaturile. Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un `X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64). diff --git a/app/web/ratelimit.py b/app/web/ratelimit.py index 64baeb6..02bb914 100644 --- a/app/web/ratelimit.py +++ b/app/web/ratelimit.py @@ -1,6 +1,6 @@ -"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5. +"""Rate-limit in-proces cu fereastra glisanta. -Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client. +Fara dependinta externa. Folosit de POST /signup cu cheia = IP client. Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py). """ diff --git a/app/web/routes.py b/app/web/routes.py index 91dcb09..17883ed 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -1,12 +1,11 @@ """Dashboard Jinja2 + HTMX (server-rendered, zero build). Schelet cu stari explicite: empty (coada goala), banner alerta blocate, -worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator + -export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review). +worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator. -U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite). -Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica. -Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX. +Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite). +Consuma helper-e interne din import_router fara a le modifica. Toate rutele /_import/* +returneaza fragmente HTML targetate pe #import-section prin HTMX. """ from __future__ import annotations @@ -83,12 +82,12 @@ from ..mapping import ( text_rules_overlap, ) -# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5) +# Campuri canonice cu eticheta umana pentru dropdown mapare coloane _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()] router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) -# Expune parse_erori in toate template-urile (US-006, PRD 5.4) +# Expune parse_erori in toate template-urile templates.env.globals["parse_erori"] = parse_erori _BLOCKED = ("error", "needs_data", "needs_mapping") @@ -98,7 +97,7 @@ def _ctx(request: Request, **extra) -> dict: """Context de baza pentru template-uri cu formulare: include mereu csrf_token. Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare - trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8). + trebuie sa includa csrf_token negol altfel urmatorul submit da 403. """ return {"request": request, "csrf_token": get_csrf_token(request), **extra} @@ -161,15 +160,14 @@ def _rar_state(hb, worker_alive: bool) -> str: return "indisponibil?" if age > 108000 else "ok" -# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import -# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. -# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. -# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan. +# "import" si "coada" nu mai sunt tab-uri separate — importul si Trimiterile sunt +# sectiuni pe Acasa. ?tab=import / ?tab=coada cad pe Acasa (fallback in dashboard()), +# fara 404 si fara fragment orfan. _TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: - """Calculeaza contextul pentru panoul Acasa (US-005). + """Calculeaza contextul pentru panoul Acasa. Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). """ @@ -197,8 +195,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: ).fetchone() are_cheie_folosita = row_key is not None - # US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul - # sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut. + # Contorul de atentie (blocate) se reflecta in heading-ul sectiunii + # "Trimiterile tale" de pe Acasa. counts = _status_counts(conn, account_id) blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) @@ -212,7 +210,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: "pills_categorii": _pills_categorii(counts), # Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor. "versiune_trimiteri": _trimiteri_versiune(conn, account_id), - # US-002: Acasa include caseta de upload -> are nevoie de csrf_token + # Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), } @@ -220,8 +218,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str: """Randeaza panoul Acasa ca string HTML. - `status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de - stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end). + `status`: deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de stare in + sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end). """ if conn is None: return templates.get_template("_acasa.html").render( @@ -243,8 +241,8 @@ def _render_panel_import(request: Request) -> str: def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str: - """US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa - (Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi.""" + """"coada" nu mai e panou propriu — serveste continutul Acasa (Trimiterile sunt + sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi.""" return _render_panel_acasa(request, conn, account_id) @@ -334,10 +332,10 @@ def _jurnal_context( data_de: str | None = None, data_pana: str | None = None, cont: str | None = None, page: int = 0, ) -> dict: - """Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope. + """Context pentru tab-ul Jurnal: evenimente paginate + filtre + scope. Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele - contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5. + contului sau (regula NULL->cont 1, ca restul UI-ului). """ admin = is_account_admin(conn, account_id) tip = (tip or "").strip() or None @@ -397,7 +395,7 @@ def _jurnal_context( def _render_panel_jurnal(request: Request, conn, account_id: int) -> str: - """Randeaza panoul Jurnal ca string HTML (US-006).""" + """Randeaza panoul Jurnal ca string HTML.""" return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id)) @@ -424,20 +422,20 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: - """Dashboard principal cu tab-uri (US-003). + """Dashboard principal cu tab-uri. Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS). - Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista - Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta"). + Tab invalid -> fallback la 'acasa'. `?status=` pre-filtreaza lista Trimiteri de + pe Acasa (deep-link din banner-ul "Necesita atentia ta"). """ account_id = require_login(request) active_tab = tab if tab in _TABS_VALIDE else "acasa" conn = get_connection() try: panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status) - # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele - # (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). + # Badge contoare pe tab-uri: needs_mapping -> Mapari. Blocatele se reflecta in + # heading-ul sectiunii Trimiteri. counts = _status_counts(conn, account_id) badges = { "mapari": counts.get("needs_mapping", 0), @@ -460,7 +458,7 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) - @router.get("/_fragments/acasa", response_class=HTMLResponse) def fragment_acasa(request: Request) -> HTMLResponse: - """Fragment HTMX pentru tab-ul Acasa (US-003, US-005).""" + """Fragment HTMX pentru tab-ul Acasa.""" account_id = require_login(request) conn = get_connection() try: @@ -472,16 +470,16 @@ 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 (US-003).""" + """Fragment HTMX pentru tab-ul Import — include zona de upload.""" require_login(request) return templates.TemplateResponse("_upload.html", _ctx(request)) @router.get("/_fragments/coada", response_class=HTMLResponse) def fragment_coada(request: Request) -> HTMLResponse: - """US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa - (Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html` - orfan din bookmark-uri/HTMX vechi. Nu da 404.""" + """"coada" nu mai are fragment propriu. Serveste continutul Acasa (Trimiterile sunt + sectiune permanenta pe Acasa) — evita un fragment `_coada.html` orfan din + bookmark-uri/HTMX vechi. Nu da 404.""" account_id = require_login(request) conn = get_connection() try: @@ -528,7 +526,7 @@ def fragment_jurnal( cont: str | None = None, page: int = 0, ) -> HTMLResponse: - """Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont. + """Tab Jurnal: evenimente app_events paginate + filtre, scoped pe cont. Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii. """ @@ -563,8 +561,8 @@ def fragment_banner(request: Request) -> HTMLResponse: def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: """Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0. - Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD. - Returneaza lista goala daca nu exista nicio stare blocata. + Ordinea: needs_mapping, needs_data, error. Returneaza lista goala daca nu + exista nicio stare blocata. """ rezultat = [] for status in ("needs_mapping", "needs_data", "error"): @@ -575,13 +573,12 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: def _pills_categorii(counts: dict[str, int]) -> list[dict]: - """Pill-uri pentru starile cu problema (US-003 PRD 5.10). + """Pill-uri pentru starile cu problema. - Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand). - Reutilizeaza contoarele deja calculate din _status_counts. - Returneza lista goala daca nu exista nicio stare blocata. + Reutilizeaza contoarele deja calculate din _status_counts (fara PII/VIN per rand). + Returneaza lista goala daca nu exista nicio stare blocata. """ - # DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu). + # Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu). # Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului, # pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill). PILL_DEFS = [ @@ -598,7 +595,7 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]: @router.get("/_fragments/status", response_class=HTMLResponse) def fragment_status(request: Request) -> HTMLResponse: - """Bara de status persistenta cu etichete umane (US-002, PRD 3.4). + """Bara de status persistenta cu etichete umane. Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima autentificare, contorii de coada si defalcarea blocatelor pe motiv. @@ -623,7 +620,7 @@ def fragment_status(request: Request) -> HTMLResponse: "request": request, "worker_lbl": worker_lbl, "rar_lbl": rar_lbl, - # Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare + # Stari binare pentru bife accesibile: glifa + culoare "worker_ok": worker_alive, "rar_ok": rar_ok, "eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR, @@ -657,9 +654,8 @@ def _iso_date_prefix(value: object) -> str | None: Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda (ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data - fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10). - Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si - sunt excluse din filtru — comportament actual pastrat. + fara a exclude timestamp-urile. Valori care nu incep cu o data ISO valida + (ex. '05.12.2024') intorc None si sunt excluse din filtru. """ s = str(value or "").strip() if len(s) < 10: @@ -673,16 +669,16 @@ def _iso_date_prefix(value: object) -> str | None: # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana -# scurta de pe rand (US-001, R1) e ne-goala DOAR pe acestea; pe queued/sending/sent e "". +# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "". _STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping") def _eticheta_problema(status: str, motiv: str) -> str: - """Eticheta umana scurta a problemei pentru randul de tabel (US-001, R1). + """Eticheta umana scurta a problemei pentru randul de tabel. Reutilizeaza `motiv` (motiv_uman, deja calculat in randul de view) si cade pe - `eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (R1: DRY, fara - al 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand. + `eticheta_scurta` cand motivul e gol — NU re-parseaza `rar_error` (DRY, fara al + 3-lea decoder). Codul BRUT de catalog ramane doar pentru modal, nu pe rand. Sir gol pe stari fara problema (queued/sending/sent); ne-gol pe error/needs_*. Defensiv: motiv_uman nu arunca, iar starile cu problema au intotdeauna eticheta @@ -694,13 +690,13 @@ def _eticheta_problema(status: str, motiv: str) -> str: def _submission_row_view(r) -> dict: - """Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004).""" + """Imbogateste un rand de submission cu campuri afisabile umane.""" eticheta = eticheta_stare(r["status"]) motiv = motiv_uman(r["status"], r["rar_error"]) return { "id": r["id"], "status": r["status"], - # PRD 5.8 US-007/US-006: pill = eticheta scurta; textul lung ramane ca tooltip (title=). + # pill = eticheta scurta; textul lung ramane ca tooltip (title=). "stare_scurt": eticheta_scurta(r["status"]), "stare_text": eticheta[0], "stare_css": eticheta[2], @@ -708,15 +704,15 @@ def _submission_row_view(r) -> dict: "id_prezentare": r["id_prezentare"], "updated_at": format_data_rar(r["updated_at"]), "motiv": motiv, - # US-001/R1: eticheta umana scurta a problemei sub pill (text, nu cod brut). + # eticheta umana scurta a problemei sub pill (text, nu cod brut). "eticheta_problema": _eticheta_problema(r["status"], motiv), - # US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile - # pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox). + # 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, } -_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10) +_PAGE_SIZE = 25 # Marime pagina fixa @router.get("/_fragments/submissions", response_class=HTMLResponse) @@ -728,9 +724,9 @@ def fragment_submissions( data_pana: str | None = None, page: int = 1, ) -> HTMLResponse: - """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004). + """Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare. - US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru: + Totalul se calculeaza DIFERIT dupa tipul de filtru: - FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET - CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru). @@ -756,8 +752,8 @@ def fragment_submissions( where_sql = " AND ".join(where) if filtru_python: - # Calea B: fetch-all, filtreaza in Python, slice (US-004 H1) - # FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1) + # Calea B: fetch-all, filtreaza in Python, slice. + # 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", @@ -773,7 +769,7 @@ def fragment_submissions( if vehicul_q not in hay: continue if data_de or data_pana: - # Extragem portiunea YYYY-MM-DD (US-001 fix). + # Extragem portiunea YYYY-MM-DD. d_prefix = _iso_date_prefix(prez["data_prestatie"]) if d_prefix is None: continue @@ -785,7 +781,7 @@ def fragment_submissions( total = len(view_all) pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 - page = max(1, min(page, pages)) # clamp H2 + page = max(1, min(page, pages)) # clamp la nr. de pagini offset = (page - 1) * _PAGE_SIZE view = view_all[offset:offset + _PAGE_SIZE] @@ -796,7 +792,7 @@ def fragment_submissions( ).fetchone()[0] pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1 - page = max(1, min(page, pages)) # clamp H2 + page = max(1, min(page, pages)) # clamp la nr. de pagini offset = (page - 1) * _PAGE_SIZE rows_db = conn.execute( @@ -815,13 +811,13 @@ def fragment_submissions( "rows": view, "filtru_activ": filtru_activ, "csrf_token": get_csrf_token(request), - # Paginare (US-004) + # Paginare "total": total, "page": page, "pages": pages, "page_start": page_start, "page_end": page_end, - # Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2) + # Filtre curente pentru linkurile de paginare (pastreaza filtrele) "f_status": status or "", "f_vehicul": vehicul_q or "", "f_data_de": data_de or "", @@ -835,17 +831,17 @@ def fragment_submissions( conn.close() -# Stari ne-trimise blocate pe care le putem corecta inline (US-010). +# Stari ne-trimise blocate pe care le putem corecta inline. _CORECTABILE = ("needs_data", "needs_mapping") -# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error -# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields). +# Stari cu select editabil cod_prestatie (superset al _CORECTABILE: error primeste +# select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields). _EDITABILE_OP = ("needs_data", "needs_mapping", "error") -# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada. +# Stari gestionabile prin lifecycle web: sterge / re-pune in coada. _GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse: - """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011).""" + """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk.""" scope_sql, scope_params = account_scope_clause(account_id) rows = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " @@ -865,7 +861,7 @@ def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse def _payload_form_values(payload_json) -> dict: - """Valori brute pentru prefill-ul formularului de corectie (US-010).""" + """Valori brute pentru prefill-ul formularului de corectie.""" try: data = json.loads(payload_json) if payload_json else {} if not isinstance(data, dict): @@ -882,7 +878,7 @@ def _payload_form_values(payload_json) -> dict: def _nemapate_pentru_submission(row, nomenclator: list[dict]) -> list[dict]: - """Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy (PRD 5.7). + """Operatiile nemapate ale UNUI submission needs_mapping, cu sugestii fuzzy. Echivalentul `pending_unmapped` restrans la un singur rand: parseaza payload_json, aduna prestatiile fara cod_prestatie (cu cod_op_service) si ataseaza sugestii din @@ -921,12 +917,12 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, """Context pentru _trimitere_detaliu.html dintr-un rand de submission. `conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune - `nemapate_inline` + `nomenclator` pentru maparea inline din panou (PRD 5.7). + `nemapate_inline` + `nomenclator` pentru maparea inline din panou. """ eticheta = eticheta_stare(row["status"]) nemapate_inline: list[dict] = [] nomenclator: list[dict] = [] - # Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006) + # Nomenclatorul complet, incarcat pentru needs_mapping si refolosit mai jos. _nomenclator_complet: list[dict] = [] if conn is not None and row["status"] == "needs_mapping": # Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown. @@ -934,14 +930,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet) nomenclator = _nomenclator_complet if nemapate_inline else [] - # US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in - # formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet - # daca e deja incarcat (needs_mapping), altfel incarca fresh. + # Nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in formularul + # /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet daca + # e deja incarcat (needs_mapping), altfel incarca fresh. nomenclator_rar: list[dict] = [] if conn is not None and row["status"] in _EDITABILE_OP: nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn) - # US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select) + # cod_prestatie curent din prima prestatie (pentru pre-selectare in select) cod_prestatie_curent = "" try: _pd = json.loads(row["payload_json"] or "{}") @@ -969,14 +965,14 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "created_at": format_data_rar(row["created_at"]), "updated_at": format_data_rar(row["updated_at"]), "next_attempt_at": format_data_rar(row["next_attempt_at"]), - # randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu + # randuri ne-trimise blocate sunt corectabile; sent/sending nu "editabil": row["status"] in _CORECTABILE, - # US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada + # error/needs_data/needs_mapping pot fi sterse / re-puse in coada "gestionabil": row["status"] in _GESTIONABILE_WEB, - # PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator) + # mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, "nomenclator": nomenclator, - # US-006: select cod_prestatie pentru stari editabile + # select cod_prestatie pentru stari editabile "nomenclator_rar": nomenclator_rar, "cod_prestatie_curent": cod_prestatie_curent, "corectie_msg": message, @@ -988,7 +984,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, def _fetch_submission_scoped(conn, account_id: int, submission_id: int): - """Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3).""" + """Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta).""" scope_sql, scope_params = account_scope_clause(account_id) return conn.execute( f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", @@ -996,14 +992,14 @@ def _fetch_submission_scoped(conn, account_id: int, submission_id: int): ).fetchone() -# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e -# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload). +# Campuri afisate in detaliul trimiterii. payload_json e plaintext si se foloseste +# doar pentru campurile derivate (prezentare_din_payload). @router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse) def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse: """Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu). Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont - (acelasi mesaj, nu confirmam existenta — vezi B3/router.py). + (acelasi mesaj, nu confirmam existenta). """ account_id = require_login(request) conn = get_connection() @@ -1021,7 +1017,7 @@ def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResp @router.post("/trimitere/{submission_id}/mapeaza", response_class=HTMLResponse) async def post_mapeaza_inline(request: Request, submission_id: int) -> HTMLResponse: - """Mapare inline din panoul de detaliu (PRD 5.7): alege cod RAR pentru o operatie nemapata. + """Mapare inline din panoul de detaliu: alege cod RAR pentru o operatie nemapata. Reutilizeaza EXACT save_mapping + reresolve_account (ca tab-ul Mapari) — fara logica noua de clasificare. Re-rezolva scoped pe batch-ul randului (canal API batch_id IS NULL @@ -1108,10 +1104,9 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR if isinstance(val, str) and val.strip() != "": content[camp] = val.strip() - # US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii. - # Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou - # e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md - # invariant "build_key hashuieste cod_prestatie, idempotency.py:34"). + # Injectare cod_prestatie din form INAINTE de resolve_prestatii. Oglindeste + # validarea din post_mapeaza_inline (nomenclator check). Codul nou e injectat in + # prima prestatie (index 0); build_key il include in hash. _cod_raw = form.get("cod_prestatie") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") if cod_prestatie_form: @@ -1143,7 +1138,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules) content["prestatii"] = resolved - # US-010: telemetrie pentru itemii rezolvati prin regula text (calea corectie web). + # telemetrie pentru itemii rezolvati prin regula text (calea corectie web). _emite_text_rule_hits(conn, account_id, row["id"], resolved) # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. @@ -1238,8 +1233,8 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR "_trimitere_detaliu.html", _detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."), ) - # PRD 5.9 US-003 (R5): pe succes, lista se reincarca (trimiteriChanged) si modalul - # se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului. + # Pe succes, lista se reincarca (trimiteriChanged) si modalul se inchide + # (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului. resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal" return resp finally: @@ -1247,26 +1242,26 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR # =========================================================================== # -# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada # -# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. # +# Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada. # +# Peste helper-ul submissions_admin. CSRF enforce; scoped pe sesiune. # # =========================================================================== # @router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse) async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse: """Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard. - US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`, - actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada - direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune - (404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu - starea noua + nudge `trimiteriChanged` pentru lista. + Daca randul e in starea `error` si formularul contine `cod_prestatie`, actualizeaza + codul in payload, recalculeaza cheia de idempotency si re-pune in coada direct (fara + `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune (404 + cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu starea + noua + nudge `trimiteriChanged` pentru lista. """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) conn = get_connection() try: - # US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission + # Prelucrare cod_prestatie pentru starea error (inaintea requeue_submission # standard, care nu actualizeaza cheia de idempotency). _cod_raw = form.get("cod_prestatie") cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "") @@ -1398,7 +1393,7 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes resp = HTMLResponse( '
Trimitere stearsa.
' ) - # PRD 5.9 US-003 (R5): pe succes, lista se reincarca + modalul se inchide. + # Pe succes, lista se reincarca + modalul se inchide. resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal" return resp finally: @@ -1410,7 +1405,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse: """Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune). Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a - opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri. + opri operatia — pe modelul panoului admin. Re-randeaza lista Trimiteri. """ account_id = require_login(request) form = await request.form() @@ -1436,7 +1431,7 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse: def _load_saved_op_mappings(conn, account_id: int) -> list[dict]: """Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele - prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu).""" + prestatiei jonctionat din nomenclator. Scoped pe cont (NOT NULL → simplu).""" acct = account_or_default(account_id) rows = conn.execute( "SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie " @@ -1458,7 +1453,7 @@ def _load_saved_op_mappings(conn, account_id: int) -> list[dict]: def _load_column_formats(conn, account_id: int) -> list[dict]: - """Formate de coloane salvate (column_mappings) ale contului (US-006). + """Formate de coloane salvate (column_mappings) ale contului. Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont. """ @@ -1507,7 +1502,7 @@ def _render_mapari( def fragment_mapari(request: Request) -> HTMLResponse: """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR. - Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit. + Scoped pe contul sesiunii: pending_unmapped primeste account_id explicit. """ account_id = require_login(request) conn = get_connection() @@ -1547,7 +1542,7 @@ def post_mapare( # =========================================================================== # -# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere # +# Mapari operatii salvate: editare cod/auto-send + stergere. # # CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. # # =========================================================================== # @@ -1611,7 +1606,7 @@ def post_sterge_mapare_salvata( # =========================================================================== # -# US-004 (5.8) — Reguli automate (text): substring -> cod RAR # +# Reguli automate (text): substring -> cod RAR. # # Adaugare/stergere reguli text scoped pe sesiune; salvarea re-rezolva blocajele.# # =========================================================================== # @@ -1628,7 +1623,7 @@ def post_salveaza_regula_text( Scoped pe contul sesiunii (save_text_rule foloseste account_or_default(sesiune)). Valideaza cod_prestatie fata de nomenclator INAINTE de save (cod necunoscut -> respins inline, fara salvare). La succes: mesaj „Regula salvata. Deblocate: N" - + trigger trimiteriChanged (refresh lista), ca maparea inline (5.7). + + trigger trimiteriChanged (refresh lista), ca maparea inline. """ account_id = require_login(request) verify_csrf(request, csrf_token) @@ -1647,7 +1642,7 @@ def post_salveaza_regula_text( request, conn, account_id, message=f"Cod RAR necunoscut in nomenclator: {cod}.", ) - # US-011: avertisment neblocant daca regula noua se suprapune (substring, + # avertisment neblocant daca regula noua se suprapune (substring, # oricare directie) cu una existenta. Calculam INAINTE de save, fata de # regulile curente, ca pattern-ul nou sa nu se compare cu sine. overlap = text_rules_overlap(pat, load_text_rules(conn, account_id)) @@ -1696,7 +1691,7 @@ def post_preview_regula_text( pattern: str = Form(""), csrf_token: str | None = Form(None), ) -> HTMLResponse: - """Preview pre-salvare (US-009): cate operatii nemapate ar potrivi regula. + """Preview pre-salvare: cate operatii nemapate ar potrivi regula. NU salveaza nimic (zero scriere DB). Normalizeaza pattern-ul cu normalize_for_match, numara operatiile DISTINCTE nemapate ale contului @@ -1743,7 +1738,7 @@ def post_preview_regula_text( # =========================================================================== # -# US-006 — Formate de coloane salvate: editare format data + stergere # +# Formate de coloane salvate: editare format data + stergere. # # CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). # # =========================================================================== # @@ -1879,7 +1874,7 @@ def _web_compute_preview( if not raw_rows_db: return "Niciun rand in batch." - # Decripteaza randurile + override-urile editate (3.6) + # Decripteaza randurile + override-urile editate rows: list[dict[str, Any]] = [] overrides: list[dict[str, Any]] = [] for r in raw_rows_db: @@ -1907,12 +1902,12 @@ def _web_compute_preview( json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) format_data: str | None = mapping_row["format_data"] - # Mapare operatii (o singura incarcare — Eng#5) + # Mapare operatii (o singura incarcare) mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} - # US-010/US-003 (paritate): preview-ul web trebuie sa aplice ACELEASI reguli text + - # validare nomenclator ca si commit-ul (2426), altfel un rand rezolvabil doar prin - # regula text ar fi marcat needs_mapping si exclus din commit. Incarcate o data. + # Paritate cu commit-ul: preview-ul web trebuie sa aplice ACELEASI reguli text + + # validare nomenclator, altfel un rand rezolvabil doar prin regula text ar fi marcat + # needs_mapping si exclus din commit. Incarcate o data. valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) @@ -1980,7 +1975,7 @@ def _web_compute_preview( "idempotency_key": key, }) - # Already_sent: batch lookup (Eng#5 — fara N+1) + # Already_sent: batch lookup (fara N+1) unique_keys = list(set(keys_for_lookup)) already_sent_map = _already_sent_lookup(conn, account_id, unique_keys) @@ -2091,7 +2086,7 @@ async def web_upload_import( try: sig = _signature(parsed.columns) - # Stagingul in DB (tranzactie explicita — Issue 6) + # Stagingul in DB (tranzactie explicita) conn.execute("BEGIN IMMEDIATE") try: cur = conn.execute( @@ -2330,15 +2325,15 @@ def web_preview_import( # =========================================================================== # -# US-002 (3.6) — Editare celule in preview: mod editare pe rand. # -# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). # -# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). # +# Editare celule in preview: mod editare pe rand. # +# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section. # +# Status rederivat DOAR prin _resolve_row_for_preview (fara clasificator). # # =========================================================================== # def _preview_one_row(conn, import_id: int, account_id: int, row_index: int): """Recalculeaza preview-ul si extrage un singur rand. - Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat), + Statusul e rederivat prin `_resolve_row_for_preview` (fara clasificator duplicat), iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None).""" result = _web_compute_preview(conn, import_id, account_id) @@ -2366,7 +2361,7 @@ def _render_preview_rand( @router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse) def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: - """Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3).""" + """Intra in mod editare pe un rand de preview (randul devine FORM propriu).""" account_id = require_login(request) conn = get_connection() try: @@ -2400,11 +2395,11 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe @router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: - """Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul. + """Persista override (mutatie pura) + re-randeaza DOAR randul. - Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB - contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare - cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2).""" + Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare. + Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate + si mesajul pe campul vinovat.""" account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) @@ -2502,8 +2497,8 @@ async def web_confirma_import( Replica logica din import_router.commit_import dar cu input din form HTML si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU). - C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile. - C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod. + account_id din sesiune propagat consecvent la build_key si toate lookup-urile. + require_login — pe scrieri NICIODATA fallback cont 1 in prod. """ account_id = require_login(request) acct = account_or_default(account_id) @@ -2644,11 +2639,11 @@ async def web_confirma_import( # Mapare operatii mapping_meta = load_mapping_meta(conn, acct) mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} - # T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. + # validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri. valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, acct) - # Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU) + # Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU) enqueued: list[dict] = [] toctou: list[int] = [] rows_for_hash: list[str] = [] @@ -2696,7 +2691,7 @@ async def web_confirma_import( "odometru_final": canon["odometru_final"], }) - # Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver. + # Override editat in preview — aplicat ULTIMUL, ca in resolver. override = item.get("override") or {} if override: mapped.update(override) @@ -2729,7 +2724,7 @@ async def web_confirma_import( if cur.rowcount == 0: toctou.append(row_index) else: - # US-010: telemetrie pentru itemii rezolvati prin regula text. + # telemetrie pentru itemii rezolvati prin regula text. _emite_text_rule_hits(conn, acct, int(cur.lastrowid), resolved_p) enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index}) @@ -2740,7 +2735,7 @@ async def web_confirma_import( n_enqueued = len(enqueued) - # Log atestare (Voce#9) + # Log atestare rows_hash = hashlib.sha256( json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8") ).hexdigest() if rows_for_hash else "" @@ -2757,7 +2752,7 @@ async def web_confirma_import( # Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True: # contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie - # sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004). + # sectiunea "Trimiterile tale" de pe Acasa. toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" return templates.TemplateResponse("_upload.html", _ctx( request, @@ -2773,8 +2768,8 @@ async def web_confirma_import( # =========================================================================== # -# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI # -# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds +# Sectiune "Contul meu": rotire cheie API + creds RAR din UI. # +# Rute web proprii scoped pe sesiune (nu reutilizeaza /v1/conturi/rar-creds # # care cere cheie API; sesiunea web e suficienta ca identitate). # # =========================================================================== # @@ -2846,10 +2841,9 @@ def integrare_test_cheie( ) -> HTMLResponse: """Verifica cheia API lipita de utilizator — scoped pe contul sesiunii. - US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata - din generatorul de exemple corespunde contului sau, fara efecte secundare - (fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de - eroare neutru (fara eco al cheii in raspuns). + Permite utilizatorului sa confirme ca o cheie copiata din generatorul de exemple + corespunde contului sau, fara efecte secundare (fara creare/rotire). Cheie goala, + invalida sau a altui cont -> mesaj de eroare neutru (fara eco al cheii in raspuns). """ account_id = require_login(request) verify_csrf(request, csrf_token) @@ -2901,7 +2895,7 @@ def cont_rar_creds( rar_parola: str = Form(""), csrf_token: str | None = Form(None), ) -> HTMLResponse: - """Seteaza creds RAR per cont din sesiune (ruta web proprie, C13). + """Seteaza creds RAR per cont din sesiune (ruta web proprie). Camp parola NICIODATA re-pus in value= la re-randare. Validare minima: email si parola negoale. diff --git a/app/web/session.py b/app/web/session.py index 0417370..fc2d4c6 100644 --- a/app/web/session.py +++ b/app/web/session.py @@ -1,6 +1,6 @@ -"""Helper-e sesiune web. US-002 PRD 3.3. +"""Helper-e sesiune web. -Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse +Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse (acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb: - require_login() RIDICA LoginRequired - app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce @@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None: def current_user_id(request: Request) -> int | None: - """user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by).""" + """user_id din sesiune sau None (leaga import_attestations.confirmed_by).""" val = request.session.get("user_id") return int(val) if val is not None else None @@ -88,7 +88,7 @@ def require_admin(request: Request) -> int: def set_session(request: Request, account_id: int, user_id: int) -> None: - """Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune).""" + """Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune).""" request.session.clear() request.session["account_id"] = account_id request.session["user_id"] = user_id diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index 17130a4..d41770b 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -44,12 +44,8 @@ {% endif %} - {# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea - traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #} - - {# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003). - Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul, - iar empty-state-ul tabelului ar fi redundant (US-004 / D-5.1). === #} + {# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero + trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #} {% if are_trimiteri %} {% include '_coada.html' %} {% endif %} diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 5f97312..b3cf15a 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -1,8 +1,6 @@ {# - _coada.html — repurposat in 3.6 (US-003). - Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa, - sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul - se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5). + _coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload. + Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu). #}
@@ -68,8 +66,4 @@ - {# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html), - in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza - deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu - a fost eliminat (rol preluat de modal). #}
diff --git a/app/web/templates/_eroare.html b/app/web/templates/_eroare.html index 9902147..a625b93 100644 --- a/app/web/templates/_eroare.html +++ b/app/web/templates/_eroare.html @@ -1,5 +1,5 @@ {# - _eroare.html — macro card_erori(erori) (US-006, PRD 5.4). + _eroare.html — macro card_erori(erori). Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None). Afiseaza 3 niveluri intr-un bloc scannabil: diff --git a/app/web/templates/_jurnal.html b/app/web/templates/_jurnal.html index f3b8c62..5f2ccba 100644 --- a/app/web/templates/_jurnal.html +++ b/app/web/templates/_jurnal.html @@ -1,6 +1,6 @@ -{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6). +{# _jurnal.html — tab Jurnal de aplicatie. Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/ - data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #} + data + (admin) cont. #}
diff --git a/app/web/templates/_macros.html b/app/web/templates/_macros.html index e47ac9f..18622b1 100644 --- a/app/web/templates/_macros.html +++ b/app/web/templates/_macros.html @@ -1,18 +1,14 @@ {# Macro-uri partajate intre template-urile de import si mapari. #} -{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara - proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari). - Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de - tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport). - - INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"` - si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False). +{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand. + INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si + SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False). E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())` la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual - Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend. + Manual<->Auto peste checkbox, NU doua radio-uri. - form_id: leaga input-ul de un
extern (necesar in celulele de tabel). - - checked: starea STOCATA per mapare (H4) — bifat = Auto. #} + - checked: starea STOCATA per mapare — bifat = Auto. #} {% macro autosend_toggle(form_id='', checked=True, label='') -%}