diff --git a/app/users.py b/app/users.py index 89a44f5..b758207 100644 --- a/app/users.py +++ b/app/users.py @@ -157,6 +157,53 @@ def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int return None +def change_password( + conn: sqlite3.Connection, + user_id: int, + current_password: str, + new_password: str, +) -> None: + """Schimba parola de acces a userului identificat prin user_id. + + Verifica parola actuala (scrypt, comparatie constant-time), valideaza parola + noua (10..128, diferita de cea actuala) si rescrie password_hash + salt nou cu + parametrii scrypt curenti. Parola NICIODATA stocata in clar. + + Ridica ValueError la: user inexistent, parametri parola corupti, parola actuala + gresita, parola noua invalida. Nu invalideaza sesiunea (apelantul decide). + """ + row = conn.execute( + "SELECT password_hash, salt, scrypt_params FROM users WHERE id=?", + (user_id,), + ).fetchone() + if row is None: + raise ValueError("utilizator inexistent") + + salt = bytes.fromhex(row["salt"]) + expected = bytes.fromhex(row["password_hash"]) + params = _parse_scrypt_params(row["scrypt_params"] or "") + if params is None: + raise ValueError("parametri parola corupti") + n, r, p = params + actual = _scrypt_hash(current_password, salt, n=n, r=r, p=p) + if not hmac.compare_digest(actual, expected): + raise ValueError("Parola actuala este incorecta.") + + if len(new_password) < _PASSWORD_MIN: + raise ValueError(f"Parola noua e prea scurta (minim {_PASSWORD_MIN} caractere).") + if len(new_password) > _PASSWORD_MAX: + raise ValueError(f"Parola noua e prea lunga (maxim {_PASSWORD_MAX} caractere).") + if new_password == current_password: + raise ValueError("Parola noua trebuie sa difere de cea actuala.") + + new_salt = secrets.token_bytes(16) + new_hash = _scrypt_hash(new_password, new_salt) + conn.execute( + "UPDATE users SET password_hash=?, salt=?, scrypt_params=? WHERE id=?", + (new_hash.hex(), new_salt.hex(), SCRYPT_PARAMS, user_id), + ) + + def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None: """Metadate user dupa email (FARA password_hash si salt).""" row = conn.execute( diff --git a/app/web/routes.py b/app/web/routes.py index 749d46d..f0a0669 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -42,7 +42,7 @@ from .labels import ( nota_umana_preview, parse_erori, ) -from ..web.session import LoginRequired, require_login +from ..web.session import LoginRequired, current_user_id, require_login, web_account from ..api.v1.import_router import ( _already_sent_lookup, _build_idempotency_key, @@ -61,7 +61,7 @@ from ..observ import log_event from ..idempotency import build_key, canonicalize_row from ..validation import validate_prezentare from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file -from ..users import is_account_admin +from ..users import change_password, is_account_admin from ..submissions_admin import ( SubmissionNotFound, SubmissionStateConflict, @@ -99,7 +99,7 @@ _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()] def _import_env_ctx(conn, account_id: int) -> dict: - """Contextul de mediu RAR pentru paginile de import (US-009, PRD 5.20). + """Contextul de mediu RAR pentru paginile de import. Intoarce {'medii': list[str], 'env_default': str} pentru template-ul _upload.html. Un mediu e disponibil = activat SI are credentiale. La 0 medii template afiseaza @@ -120,7 +120,7 @@ templates.env.globals["eticheta_env"] = eticheta_env def _mediu_instanta() -> str: """Eticheta umana a mediului GLOBAL al instantei care ruleaza (AUTOPASS_RAR_ENV). - E ancora de fallback (PRD 5.20): cand un cont nu are niciun mediu RAR configurat, + E ancora de fallback: cand un cont nu are niciun mediu RAR configurat, trimiterile cad pe acest mediu global. Expusa in template-uri ca mesaj onest despre ce mediu foloseste instanta curenta. Nu arunca niciodata. """ @@ -128,7 +128,7 @@ def _mediu_instanta() -> str: return "Productie" if env == "prod" else "Testare" -# Expune mediul global al instantei (ancora fallback PRD 5.20) pentru mesaje oneste. +# Expune mediul global al instantei (ancora fallback) pentru mesaje oneste. templates.env.globals["mediu_instanta"] = _mediu_instanta _BLOCKED = ("error", "needs_data", "needs_mapping") @@ -153,7 +153,7 @@ def _record_gold_validation(conn, denumire: str | None, cod_op_service: str, pass # --------------------------------------------------------------------------- # -# Analytics device-mix (US-012, PRD 5.15) # +# Analytics device-mix # # --------------------------------------------------------------------------- # _UA_MOBIL = _re.compile( @@ -247,12 +247,12 @@ def _status_counts(conn, account_id: int) -> dict[str, int]: def _held_counts(conn, account_id: int) -> tuple[int, int]: - """Contoare randuri tinute manual (PRD 5.19 US-006/US-007) pentru contul din sesiune. + """Contoare randuri tinute manual pentru contul din sesiune. Intoarce (manual_count, aged_count): - manual_count = randuri `queued AND held=1` (contorul "In asteptare (manual)"). - aged_count = subset mai vechi de `held_warn_days` (banner de conformitate L.142). - Derivat, fara stare noua; varsta pe `created_at` (US-007 AC). + Derivat, fara stare noua; varsta pe `created_at`. """ days = int(get_settings().held_warn_days) row = conn.execute( @@ -347,7 +347,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: acct = account_or_default(account_id) # Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet) - # US-013: citim exclusiv sloturile per-env (legacy accounts.rar_creds_enc a fost dropat). + # Citim exclusiv sloturile per-env (legacy accounts.rar_creds_enc a fost dropat). row = conn.execute( "SELECT id, name, cui, email, rar_creds_test_enc, rar_creds_prod_enc " "FROM accounts WHERE id=?", (acct,) @@ -355,7 +355,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: are_creds = bool(row and ( row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] )) - # Banner cont incomplet (US-002): contul nu are companie + email + CUI complete + # Banner cont incomplet: contul nu are companie + email + CUI complete cont_incomplet = not _acct_is_complete(row) if row else False # Pas 3: are cel putin un submission (trimis sau in coada)? @@ -378,7 +378,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: counts = _status_counts(conn, account_id) blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) - # US-005 (5.19): buton bulk "Trimite toate (N)" — N = randuri tinute ale contului. + # Buton bulk "Trimite toate (N)" — N = randuri tinute ale contului; # env-ul tinta al eliberarii (badge in modalul de confirmare tipata). held_manual_count, _held_aged = _held_counts(conn, account_id) env_bulk = rar_env_efectiv_cont(conn, account_id) or "prod" @@ -386,7 +386,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: return { "request": request, "are_creds": are_creds, - # US-005: coada tinuta manual + mediul RAR tinta pentru butonul "Trimite toate". + # Coada tinuta manual + mediul RAR tinta pentru butonul "Trimite toate". "held_manual_count": held_manual_count, "env_bulk": env_bulk, "are_trimiteri": are_trimiteri, @@ -398,7 +398,7 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: "versiune_trimiteri": _trimiteri_versiune(conn, account_id), # Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), - # Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI) + # Banner ne-blocant: contul nu are identitate completa (companie+email+CUI) "cont_incomplet": cont_incomplet, } @@ -464,6 +464,9 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: "account_meta": account_meta, "date_firma_mesaj": None, "date_firma_eroare": None, + "activ_subtab": "firma", + "parola_mesaj": None, + "parola_eroare": None, "creds_test_mesaj": None, "creds_test_eroare": None, "creds_prod_mesaj": None, @@ -472,7 +475,7 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: "creds_default_mesaj": None, **env_ctx, } - # US-006 (5.17): context plan pentru sectiunea Plan din _cont.html. + # Context plan pentru sectiunea Plan din _cont.html. cont_ctx.update(_plan_ctx(conn, account_id)) return templates.get_template("_cont.html").render(cont_ctx) @@ -591,7 +594,7 @@ def _jurnal_context( "has_more": has_more, "prev_page": page - 1 if page > 0 else None, "next_page": page + 1 if has_more else None, - # US-010 (PRD 5.20): mediul implicit al contului pentru badge de sectiune. + # Mediul implicit al contului pentru badge de sectiune. "env_default": rar_env_efectiv_cont(conn, account_id) or "test", } @@ -622,7 +625,7 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, sta return _render_panel_acasa(request) -# Etichete tier pentru badge in antet (US-010 PRD 5.16). +# Etichete tier pentru badge in antet. _TIER_LABELS: dict[str, str] = { "free": "Gratuit", "standard": "Standard", @@ -632,10 +635,10 @@ _TIER_LABELS: dict[str, str] = { def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict: - """Context afisaj plan (6 stari US-006 PRD 5.17) pentru _status.html, _cont.html si burger. + """Context afisaj plan pentru _status.html, _cont.html si burger. Returneaza: - plan_linie — linie completa cu copy RO (cele 6 stari) + plan_linie — linie completa cu copy RO plan_warn — True la >=80% consum sau limita atinsa (culoare + text) plan_limita_atinsa — True la 100% consum (--err in loc de --warn) trial_expirat_recent — True daca trial_until era setat si a expirat (banner one-time) @@ -674,7 +677,7 @@ def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict: except (ValueError, AttributeError, TypeError): pass - # Construieste plan_linie si stari aferente (cele 6 stari din PRD) + # Construieste plan_linie si stari aferente warn_aproape = False plan_limita_atinsa = False trial_expirat_recent = False @@ -720,16 +723,14 @@ def _plan_ctx(conn, account_id: int, now: datetime | None = None) -> dict: def _layout_header_ctx(conn, account_id: int) -> dict: - """Context suplimentar pentru antetul branduit (US-010/003, PRD 5.16). + """Context suplimentar pentru antetul branduit. Citeste account_name, tier si starea de sanatate RAR pentru a popula: - account_name: numele service-ului, afisat sub titlu cand logat - tier_label: eticheta planului (Gratuit/Standard/Pro/Premium) - sanatate_ok: True daca worker viu si RAR ok (dot verde in antet) - last_login: data/ora ultimei autentificari RAR (format romanesc) - - plan_linie + plan_warn + ...: context plan US-006 (5.17) pentru burger - - Apelat aditiv din dashboard() fara a atinge alti handlere. + - plan_linie + plan_warn + ...: context plan pentru burger """ row = conn.execute( "SELECT name, tier FROM accounts WHERE id=?", (account_id,) @@ -749,11 +750,11 @@ def _layout_header_ctx(conn, account_id: int) -> dict: "tier_label": tier_label, "sanatate_ok": sanatate_ok, "last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None), - # US-004 (5.19): starea comutatorului "Trimite automat la RAR" pentru toggle-ul - # din clusterul de header (D1) — randat in afara swap-ului periodic al barei de status. + # Starea comutatorului "Trimite automat la RAR" pentru toggle-ul din clusterul + # de header — randat in afara swap-ului periodic al barei de status. "auto_send_enabled": get_auto_send(conn, account_id), } - # US-006 (5.17): context plan pentru linia detaliata din meniul burger. + # Context plan pentru linia detaliata din meniul burger. ctx.update(_plan_ctx(conn, account_id)) return ctx @@ -782,7 +783,7 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) - "csrf_token": get_csrf_token(request), }, ) - # US-012: semnal agregat de device-mix (fara PII) + # Semnal agregat de device-mix (fara PII) _log_device_mix(request, account_id) active_tab = tab if tab in _TABS_VALIDE else "acasa" conn = get_connection() @@ -805,8 +806,7 @@ def dashboard(request: Request, tab: str = "acasa", status: str | None = None) - "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } - # US-010/003 (PRD 5.16): context antet (account_name, tier, sanatate RAR). - # Adaugat aditiv, fara a atinge handlerele altora. + # Context antet (account_name, tier, sanatate RAR). ctx.update(_layout_header_ctx(conn, account_id)) return templates.TemplateResponse("dashboard.html", ctx) finally: @@ -984,11 +984,11 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa else: sanatate_text = "Declaratiile curg normal" - # US-011 (5.20): mediul RAR activ per cont pentru indicatorul din statusbar. + # Mediul RAR activ per cont pentru indicatorul din statusbar. medii_disp = medii_disponibile_cont(conn, account_id) env_default = rar_env_efectiv_cont(conn, account_id) or "prod" - # US-006/US-007 (5.19): contor "In asteptare (manual)" + banner coada tinuta imbatranita. + # Contor "In asteptare (manual)" + banner coada tinuta imbatranita. held_manual_count, held_aged_count = _held_counts(conn, account_id) status_ctx = { @@ -1012,17 +1012,16 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa "tab_activ": tab_activ, "mapari_badge": counts.get("needs_mapping", 0), "oob": oob, - # US-011: indicator mediu RAR + toggle conditionat + # Indicator mediu RAR + toggle conditionat "env_default": env_default, "medii_disponibile": medii_disp, - # US-006/US-007 (5.19): contor manual + banner coada tinuta imbatranita. "auto_send_enabled": get_auto_send(conn, account_id), "held_manual_count": held_manual_count, "held_aged_count": held_aged_count, "held_aged_days": int(get_settings().held_warn_days), "csrf_token": get_csrf_token(request), } - # US-006 (5.17): context plan pentru linia de consum/trial in _status.html. + # Context plan pentru linia de consum/trial in _status.html. status_ctx.update(_plan_ctx(conn, account_id)) return status_ctx @@ -1100,9 +1099,9 @@ def _eticheta_problema(status: str, motiv: str) -> str: def _submission_row_view(r) -> dict: """Imbogateste un rand de submission cu campuri afisabile umane.""" - # US-006 (PRD 5.19 / D5): camp derivat "tinut" calculat AICI (view-model), nu in - # template. `held` e boolean pur de coada; eticheta/pill de avertizare (amber) se - # obtin din labels.py cu held=True doar cand randul e `queued AND held=1`. + # Camp derivat "tinut" calculat AICI (view-model), nu in template. `held` e boolean + # pur de coada; eticheta/pill de avertizare (amber) se obtin din labels.py cu + # held=True doar cand randul e `queued AND held=1`. held = bool(r["held"]) if ("held" in r.keys() and r["held"] is not None) else False tinut = held and r["status"] == "queued" eticheta = eticheta_stare(r["status"], held=tinut) @@ -1110,7 +1109,7 @@ def _submission_row_view(r) -> dict: return { "id": r["id"], "status": r["status"], - # US-006: randul e tinut manual (buton "Trimite" apare DOAR pe el). + # Randul e tinut manual (buton "Trimite" apare DOAR pe el). "tinut": tinut, # pill = eticheta scurta; textul lung ramane ca tooltip (title=). "stare_scurt": eticheta_scurta(r["status"], held=tinut), @@ -1125,7 +1124,7 @@ def _submission_row_view(r) -> dict: # randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru # stergere bulk; sent/sending/queued raman read-only (fara checkbox). "gestionabil": r["status"] in _GESTIONABILE_WEB, - # US-010 (PRD 5.20): mediul RAR tinta — badge in lista. + # Mediul RAR tinta — badge in lista. "rar_env": r["rar_env"] if "rar_env" in r.keys() else None, } @@ -1171,8 +1170,8 @@ def fragment_submissions( if status: where.append("status=?") params.append(status) - # US-007 (PRD 5.19): deep-link banner "coada tinuta" -> lista filtrata pe tinute - # (queued AND held=1). Filtru derivat, fara stare noua. + # Deep-link banner "coada tinuta" -> lista filtrata pe tinute (queued AND + # held=1). Filtru derivat, fara stare noua. if held_activ: where.append("held=1") where_sql = " AND ".join(where) @@ -1248,14 +1247,14 @@ def fragment_submissions( "f_vehicul": vehicul_q or "", "f_data_de": data_de or "", "f_data_pana": data_pana or "", - # US-007 (5.19): filtrul "doar tinute" persistat prin OOB pe #f-held + paginare - # (fix /code-review #2 — altfel se pierdea la reincarcare/paginare). + # Filtrul "doar tinute" persistat prin OOB pe #f-held + paginare + # (altfel se pierdea la reincarcare/paginare). "f_held": "1" if held_activ else "", # Pill-uri (OOB) + stare activa + versiune pentru nudge-ul "Date noi". "pills_categorii": _pills_categorii(_status_counts(conn, account_id)), "status_filtru": status or "", "versiune_trimiteri": _trimiteri_versiune(conn, account_id), - # US-005 (5.19): banner bulk "Trimite toate" re-randat la fiecare reincarcare. + # Banner bulk "Trimite toate" re-randat la fiecare reincarcare. **_held_bulk_ctx(conn, account_id), }) finally: @@ -1276,7 +1275,7 @@ def _render_submissions(request: Request, conn, account_id: int, """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk. `message`: sumar optional (ex. "2 reusite, 0 esuate") afisat ca banner la - inceputul fragmentului — folosit de bulk-fix (US-010, PRD 5.15). + inceputul fragmentului — folosit de bulk-fix. """ scope_sql, scope_params = account_scope_clause(account_id) rows = conn.execute( @@ -1294,8 +1293,8 @@ def _render_submissions(request: Request, conn, account_id: int, "status_filtru": "", "f_held": "", # lista fara filtre -> curata OOB filtrul "doar tinute" "versiune_trimiteri": _trimiteri_versiune(conn, account_id), - "bulk_message": message, # US-010 (PRD 5.15): sumar bulk-fix - # US-005 (5.19): banner bulk "Trimite toate" reflecta held count curent (fix staleness). + "bulk_message": message, # sumar bulk-fix + # Banner bulk "Trimite toate" reflecta held count curent (evita staleness). **_held_bulk_ctx(conn, account_id), }) @@ -1318,7 +1317,7 @@ def _payload_form_values(payload_json) -> dict: def _prestatii_chips_from_payload(payload_json) -> list[dict]: - """Extrage lista de chips prestatii din payload_json pentru _form_editare.html (US-007). + """Extrage lista de chips prestatii din payload_json pentru _form_editare.html. Returneaza lista de dicts {cod_prestatie, cod_op_service, denumire}. Itemele fara cod_prestatie (operatii nemapate) sunt incluse cu cod_prestatie=''. @@ -1402,8 +1401,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, `conn`+`account_id` (optional): cand sunt date si randul e needs_mapping, expune `nemapate_inline` + `nomenclator` pentru maparea inline din panou. """ - # US-006/US-010 (PRD 5.19): un rand `queued AND held=1` e tinut manual -> eticheta de - # avertizare + afordanta de eliberare in panoul de detaliu (paritate cu rândul din listă). + # Un rand `queued AND held=1` e tinut manual -> eticheta de avertizare + afordanta + # de eliberare in panoul de detaliu (paritate cu rândul din listă). tinut = (bool(row["held"]) if ("held" in row.keys() and row["held"] is not None) else False) \ and row["status"] == "queued" eticheta = eticheta_stare(row["status"], held=tinut) @@ -1414,7 +1413,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, if conn is not None and row["status"] == "needs_mapping": # Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown. _nomenclator_complet = load_nomenclator(conn) - # L14-S6: pasam conn pt enrich_suggestions (GOLD/SILVER/embeddings, suggestion-only) + # Pasam conn pt enrich_suggestions (GOLD/SILVER/embeddings, suggestion-only) nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet, conn=conn) nomenclator = _nomenclator_complet if nemapate_inline else [] @@ -1457,9 +1456,9 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "editabil": row["status"] in _CORECTABILE, # error/needs_data/needs_mapping pot fi sterse / re-puse in coada "gestionabil": row["status"] in _GESTIONABILE_WEB, - # US-006/US-010 (5.19): rand tinut manual (queued AND held=1) -> buton "Trimite acum". + # Rand tinut manual (queued AND held=1) -> buton "Trimite acum". "tinut": tinut, - # US-010 (PRD 5.20): mediul RAR tinta — badge in detaliu. + # Mediul RAR tinta — badge in detaliu. "rar_env": row["rar_env"] if "rar_env" in row.keys() else None, # mapare inline (operatii nemapate ale acestui rand + nomenclator) "nemapate_inline": nemapate_inline, @@ -1470,19 +1469,19 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "corectie_msg": message, "corectie_error": error, "corectie_errors": corectie_errors or [], - # US-007 (PRD 5.15): chips prestatii + obs pentru formularul slim. + # Chips prestatii + obs pentru formularul slim. # prestatii_chips: lista {cod_prestatie, cod_op_service, denumire} pentru _chips_prestatii.html. # has_r_odo: True daca chips contin R-ODO/I-ODO (trigger reveal odo initial, D10c). # obs_val: text liber observatii (campul obs din payload_json). # form_chips_url: endpoint HTMX pentru add/remove chip (E6 server-driven). } - # Chips context (US-007): derivat din payload_json + # Chips context: derivat din payload_json _pjson = row["payload_json"] prestatii_chips = _prestatii_chips_from_payload(_pjson) ctx["prestatii_chips"] = prestatii_chips ctx["has_r_odo"] = _has_r_odo_chips(prestatii_chips) ctx["form_chips_url"] = "/form-chips" - # US-009: submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. + # submission_id pentru butonul "salveaza ca regula" din _chips_prestatii.html. # Cand chips sunt rerandate via /form-chips (stateless), chips_submission_id lipseste # → butonul nu apare (corect: /form-chips nu are scop de submission). ctx["chips_submission_id"] = row["id"] @@ -1631,26 +1630,26 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR if isinstance(val, str) and val.strip() != "": content[camp] = val.strip() - # obs: text liber optional (US-005 PRD 5.15). Permite si string gol (sterge obs). + # obs: text liber optional. Permite si string gol (sterge obs). # None = absent din form (neschimbat); "" = curatare explicita. obs_val = form.get("obs") if isinstance(obs_val, str): content["obs"] = obs_val.strip() # Injectare coduri_prestatie din form (lista multi-select) INAINTE de resolve_prestatii. - # US-006 (PRD 5.15): form.getlist permite N coduri; fiecare se ataseaza itemului - # corespondent din prestatii (by index), pastrand cod_op_service/denumire (D7/E1). - # US-007 (PRD 5.15): form-ul slim trimite TOATE itemele (inclusiv "" pentru nemapate), - # permitand 1-1 aliniere by-index chiar cand un item de mijloc ramane nemapat. + # form.getlist permite N coduri; fiecare se ataseaza itemului corespondent din + # prestatii (by index), pastrand cod_op_service/denumire. Form-ul slim trimite + # TOATE itemele (inclusiv "" pentru nemapate), permitand 1-1 aliniere by-index + # chiar cand un item de mijloc ramane nemapat. # Cod necunoscut in nomenclator -> respins imediat (invariant ORA-12899). codes_raw = form.getlist("cod_prestatie") - # Acceptam lista cu "" pentru pozitii nemapate (US-007); filtrare doar pt detectia + # Acceptam lista cu "" pentru pozitii nemapate; filtrare doar pt detectia # "fara niciun cod trimis" (cazul in care form-ul nu a inclus deloc cod_prestatie). codes_positional = [ c.strip().upper() if isinstance(c, str) else "" for c in codes_raw ] - # US-006 (5.16): codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. + # Codul ales in picker dar ne-aprobat prin '+' se aplica implicit la salvare. # Picker flat (chips_add_cod_flat): cod ales dar neselectat ca chip → adaugat la sfarsit. # Picker per-operatie (chips_add_cod_{i}): cod ales pe pozitia i dar ne-aprobat → adaugat pozitional. # Ambele validate fata de nomenclator in bucla de validare de mai jos (invariant ORA-12899). @@ -1740,8 +1739,6 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."), ) - # US-001 (PRD 5.11): ramura auto_send eliminata din corectie. - errors = validate_prezentare(content) if errors: # Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp. @@ -1777,7 +1774,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR ) try: - # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada. + # Re-snapshot `held` din comutatorul contului la re-punere in coada. conn.execute( "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " @@ -1829,7 +1826,7 @@ async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLRes try: # Prelucrare coduri_prestatie (lista multi-select) pentru starea error, inaintea # requeue_submission standard care nu actualizeaza cheia de idempotency. - # US-006 (PRD 5.15): form.getlist; cod_op_service/denumire RAMAN pe item (E1 IRON RULE). + # form.getlist; cod_op_service/denumire RAMAN pe item (fara suprascriere). codes_raw = form.getlist("cod_prestatie") codes = [c.strip().upper() for c in codes_raw if isinstance(c, str) and c.strip()] @@ -1915,7 +1912,7 @@ async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLRes ), ) try: - # PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada. + # Re-snapshot `held` din comutatorul contului la re-punere in coada. conn.execute( "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " @@ -2016,9 +2013,9 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse: # =========================================================================== # -# PRD 5.19 — comutator "Trimite automat la RAR" (US-004) + trimitere manuala # -# per rand / bulk (US-005). Toate scoped pe contul sesiunii (NICIODATA din # -# formular), CSRF obligatoriu, eliberare atomica (un singur UPDATE). # +# Comutator "Trimite automat la RAR" + trimitere manuala per rand / bulk. # +# Toate scoped pe contul sesiunii (NICIODATA din formular), CSRF obligatoriu, # +# eliberare atomica (un singur UPDATE). # # =========================================================================== # @@ -2038,12 +2035,12 @@ def _auto_send_toggle_ctx(request: Request, conn, account_id: int) -> dict: @router.post("/auto-send", response_class=HTMLResponse) async def post_auto_send(request: Request) -> HTMLResponse: - """Comuta comutatorul "Trimite automat la RAR" (PRD 5.19 US-004), persistat pe cont. + """Comuta comutatorul "Trimite automat la RAR", persistat pe cont. `account_id` din SESIUNE (NICIODATA din formular). Non-optimist: raspunde cu toggle-ul re-randat din starea reala din DB (OOB outerHTML pe #auto-send-toggle-wrap). - Garda de confirmare OFF->ON (F4/D4): daca exista N>0 randuri `queued AND held=1`, NU + Garda de confirmare OFF->ON: daca exista N>0 randuri `queued AND held=1`, NU comita ON pana la o confirmare tipata (modal) — intoarce toggle-ul pe starea veche + modalul de confirmare (OOB in #modal-confirma-slot). Cu `confirma=1`: comita ON si elibereaza randurile tinute printr-un SINGUR UPDATE atomic, scoped strict pe cont. @@ -2082,7 +2079,7 @@ async def post_auto_send(request: Request) -> HTMLResponse: else: set_auto_send(conn, account_id, False) conn.commit() - # Audit (US-009): comutarea + eventuala eliberare in bloc. + # Audit: comutarea + eventuala eliberare in bloc. log_event( "auto_send_schimbat", account_id=account_or_default(account_id), mesaj=f"auto_send -> {1 if enabled else 0}", @@ -2106,7 +2103,7 @@ async def post_auto_send(request: Request) -> HTMLResponse: @router.post("/trimitere/{submission_id}/trimite-acum", response_class=HTMLResponse) async def post_trimite_acum(request: Request, submission_id: int) -> HTMLResponse: - """Trimitere manuala per rand (PRD 5.19 US-005): elibereaza un rand tinut (held 1->0). + """Trimitere manuala per rand: elibereaza un rand tinut (held 1->0). Scoped pe sesiune (404-before-leak pe id strain via `_fetch_submission_scoped`). UPDATE include `AND status='queued' AND held=1` -> no-op sigur daca randul a fost deja @@ -2145,8 +2142,8 @@ async def post_trimite_acum(request: Request, submission_id: int) -> HTMLRespons @router.post("/trimite-toate", response_class=HTMLResponse) async def post_trimite_toate(request: Request) -> HTMLResponse: - """Trimitere manuala in bloc (PRD 5.19 US-005): elibereaza TOATE randurile tinute ale - contului din sesiune (queued AND held=1 -> held=0) printr-un SINGUR UPDATE atomic scoped. + """Trimitere manuala in bloc: elibereaza TOATE randurile tinute ale contului din + sesiune (queued AND held=1 -> held=0) printr-un SINGUR UPDATE atomic scoped. NU poate elibera randurile altui cont (scope strict pe sesiune). Worker-ul preia randurile la urmatorul poll. @@ -2178,15 +2175,14 @@ async def post_trimite_toate(request: Request) -> HTMLResponse: # =========================================================================== # -# US-010 (PRD 5.15): Bulk-fix — aplica un cod RAR la selectia de randuri # -# blocate. Reuse form #bulk-trimiteri + validare cod din post_corectie. # -# Regiune izolata in routes.py (fara conflict cu alte endpoints). # +# Bulk-fix — aplica un cod RAR la selectia de randuri blocate. Reuse form # +# #bulk-trimiteri + validare cod din post_corectie. # # =========================================================================== # @router.post("/trimiteri/bulk-fix", response_class=HTMLResponse) async def post_bulk_fix(request: Request) -> HTMLResponse: - """Aplica un cod RAR la TOATE randurile blocate selectate (US-010, PRD 5.15). + """Aplica un cod RAR la TOATE randurile blocate selectate. Reuse form #bulk-trimiteri (checkbox-uri pe gestionabil). Fiecare rand e re-validat + idempotenta recalculata individual — un rand invalid nu pica lotul. @@ -2230,8 +2226,8 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None text_rules = load_text_rules(conn, account_id) - # PRD 5.19 US-002: snapshot `held` din comutatorul contului (nu depinde de rand) - # re-aplicat la re-punerea in coada — paritate cu create_prezentari/requeue_submission. + # Snapshot `held` din comutatorul contului (nu depinde de rand) re-aplicat la + # re-punerea in coada — paritate cu create_prezentari/requeue_submission. held_val = held_for_account(conn, account_or_default(account_id)) for raw in ids: @@ -2300,7 +2296,7 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: }) payload_json = json.dumps(content, ensure_ascii=False) - # Validare individuala — un rand invalid nu pica lotul (AC US-010) + # Validare individuala — un rand invalid nu pica lotul errors = validate_prezentare(content) if errors: conn.execute( @@ -2334,7 +2330,7 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: # Plasa de siguranta TOCTOU pe UNIQUE(idempotency_key) esuate += 1 - # Compune mesajul sumar "N reusite, M esuate" (AC US-010) + # Compune mesajul sumar "N reusite, M esuate" parts: list[str] = [] if reusite: suffix_r = "a" if reusite == 1 else "e" @@ -2352,16 +2348,15 @@ async def post_bulk_fix(request: Request) -> HTMLResponse: # =========================================================================== # -# US-007 (PRD 5.15): Endpoint /form-chips — re-randare chips prestatii (E6). # -# Preia starea curenta din form + actiunea (add/remove) si re-randeaza # -# _chips_prestatii.html. Fara persistenta mid-edit (stare in input-uri form). # -# Minim si izolat (regiune noua, fara conflict cu post_corecteaza). # +# Endpoint /form-chips — re-randare chips prestatii. Preia starea curenta din # +# form + actiunea (add/remove) si re-randeaza _chips_prestatii.html. Fara # +# persistenta mid-edit (stare in input-uri form). # # =========================================================================== # @router.post("/form-chips", response_class=HTMLResponse) async def post_form_chips(request: Request) -> HTMLResponse: - """Re-randeaza sectiunea chips prestatii (HTMX server-driven, E6, US-007). + """Re-randeaza sectiunea chips prestatii (HTMX server-driven). Primeste starea curenta a chip-urilor (3 liste paralele: cod_prestatie, chip_op_service, chip_denumire) + actiunea (add/remove) si returneaza @@ -2418,9 +2413,9 @@ async def post_form_chips(request: Request) -> HTMLResponse: chips.append({"cod_prestatie": add_cod_flat, "cod_op_service": "", "denumire": ""}) elif action == "add_extra": - # US-005 (5.16): Adauga cod RAR liber (extra, fara op_service) in modul operatii. - # Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item E4 pastrat). - # T-C1/T-E4: select gol sau cod invalid → chips_extra_error = True (semnal vizibil). + # Adauga cod RAR liber (extra, fara op_service) in modul operatii. + # Refoloseste `chips_add_cod_flat` (acelasi select; dedup per-item pastrat). + # Select gol sau cod invalid → chips_extra_error = True (semnal vizibil). add_cod_extra = str(form.get("chips_add_cod_flat") or "").strip().upper() if add_cod_extra: exists = conn.execute( @@ -2472,22 +2467,21 @@ async def post_form_chips(request: Request) -> HTMLResponse: "has_r_odo": has_r_odo, "form_chips_url": "/form-chips", "chips_section_id": "chips-section", - "chips_extra_error": chips_extra_error, # T-C1/T-E4 (5.16) + "chips_extra_error": chips_extra_error, }) # =========================================================================== # -# US-009 (PRD 5.15): Salvare mapare din chip. # -# Reuse EXACT save_mapping + reresolve_account (ca maparea inline 5.7). # -# Scoped pe sesiune (404 cross-account), CSRF obligatoriu. # +# Salvare mapare din chip. Reuse EXACT save_mapping + reresolve_account (ca # +# maparea inline). Scoped pe sesiune (404 cross-account), CSRF obligatoriu. # # =========================================================================== # @router.post("/trimitere/{submission_id}/salveaza-regula-chip", response_class=HTMLResponse) async def post_salveaza_regula_din_chip(request: Request, submission_id: int) -> HTMLResponse: - """Salveaza regula op->cod din chip (US-009, PRD 5.15). + """Salveaza regula op->cod din chip. - Reuse EXACT save_mapping + reresolve_account (acelasi mecanism ca maparea inline 5.7). + Reuse EXACT save_mapping + reresolve_account (acelasi mecanism ca maparea inline). Scoped pe sesiune (404 cross-account/inexistent), CSRF obligatoriu. Re-rezolva deblocand si submission-urile frate cu aceeasi operatie (pe batch_id). auto_send implicit False (conservator — userul poate activa din tab-ul Mapari). @@ -3317,7 +3311,7 @@ async def web_upload_import( try: upload_env = rezolva_rar_env(conn, account_id, rar_env or None) except MediuIndisponibil as e: - # US-012: audit mediu cerut dar indisponibil (fallback silentios, non-blocant). + # Audit mediu cerut dar indisponibil (fallback silentios, non-blocant). log_event( "rar_env_blocat", nivel="WARNING", @@ -3538,7 +3532,7 @@ async def web_save_mapare_coloane( try: mapare_env = rezolva_rar_env(conn, account_id, form_rar_env) except MediuIndisponibil as e: - # US-012: audit mediu cerut dar indisponibil la mapare coloane (fallback silentios). + # Audit mediu cerut dar indisponibil la mapare coloane (fallback silentios). log_event( "rar_env_blocat", nivel="WARNING", @@ -3576,7 +3570,7 @@ def web_preview_import( try: preview_env = rezolva_rar_env(conn, account_id, rar_env) except MediuIndisponibil as e: - # US-012: audit mediu cerut dar indisponibil la preview (fallback silentios). + # Audit mediu cerut dar indisponibil la preview (fallback silentios). log_event( "rar_env_blocat", nivel="WARNING", @@ -3673,9 +3667,9 @@ def _render_preview_rand( def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse: """Fragment editare rand preview in modalul global (#detaliu-modal-body). - US-006 (PRD 5.12): inlocuieste editarea inline (tr.preview-edit) care cauza - colapsare vizuala si eroare JS la Anuleaza (R5). Randeaza _editare_preview_modal.html. - Campurile vehicul/data/odometru sunt preluate din starea curenta (resolved + override). + Inlocuieste editarea inline (tr.preview-edit) care cauza colapsare vizuala si + eroare JS la Anuleaza. Randeaza _editare_preview_modal.html. Campurile + vehicul/data/odometru sunt preluate din starea curenta (resolved + override). """ account_id = require_login(request) conn = get_connection() @@ -3691,7 +3685,7 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> err_map[e["field"]] = e.get("message") or e.get("msg") or "" if e.get("fix"): fix_map[e["field"]] = e["fix"] - # US-007: chips prestatii + obs pentru formularul slim + # Chips prestatii + obs pentru formularul slim _preview_chips = [ { "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), @@ -3721,9 +3715,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> "vin_context": res.get("vin") or "", "btn_label": "Salveaza", "message": None, - # T2 (US-007): butonul 'Confirma valorile' apare DOAR pe randurile needs_review. + # Butonul 'Confirma valorile' apare DOAR pe randurile needs_review. "is_needs_review": row.get("resolved_status") == "needs_review", - # US-007: chips slim "prestatii_chips": _preview_chips, "has_r_odo": _preview_has_r_odo, "obs_val": (res.get("obs") or "").strip(), @@ -3738,8 +3731,8 @@ def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: """Fragment editare rand preview in modal — alias al /editare-modal. - US-006: editarea inline eliminata; ruta pastrata pentru compatibilitate cu - apeluri externe / teste existente. Delega la web_rand_editare_modal. + Editarea inline eliminata; ruta pastrata pentru compatibilitate cu apeluri + externe / teste existente. Delega la web_rand_editare_modal. """ return web_rand_editare_modal(request, import_id, row_index) @@ -3765,13 +3758,12 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: """Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal. - US-006 (PRD 5.12): - Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) + header HX-Trigger-After-Settle:inchideModal (modalul se inchide, OOB se aplica). - Eroare camp: re-randeaza _editare_preview_modal.html cu valorile introduse + erorile per-camp; modalul RAMANE DESCHIS; NU se emite inchideModal. - INVARIANT CRITIC (R2): submissions NEATINS — override-only pe import_rows.override_json, + INVARIANT CRITIC: submissions NEATINS — override-only pe import_rows.override_json, NU re-queue, NU insereaza in submissions. """ account_id = require_login(request) @@ -3807,7 +3799,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> err_map[e["field"]] = e.get("message") or e.get("msg") or "" if e.get("fix"): fix_map[e["field"]] = e["fix"] - # US-007: chips context pentru re-randare modal cu erori + # Chips context pentru re-randare modal cu erori _err_chips = [ { "cod_prestatie": (p.get("cod_prestatie") or "").strip().upper(), @@ -3837,7 +3829,6 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> "vin_context": res.get("vin") or "", "btn_label": "Salveaza", "message": "Mai sunt valori invalide — corecteaza campurile marcate.", - # US-007: chips slim "prestatii_chips": _err_chips, "has_r_odo": _has_r_odo_chips(_err_chips), "obs_val": str(form.get("obs") or res.get("obs") or "").strip(), @@ -3859,9 +3850,9 @@ async def web_confirma_review( ) -> HTMLResponse: """Confirma explicit valorile unui rand needs_review → seteaza reviewed=1 in DB. - US-007 (PRD 5.12), T2: butonul 'Confirma valorile' din modal seteaza reviewed=1 - pentru randul indicat. La recalcul (_web_compute_preview), randul cu reviewed=1 - si fara erori de validare reale devine ok (nu mai e blocat de flaguri ambigue). + Butonul 'Confirma valorile' din modal seteaza reviewed=1 pentru randul indicat. + La recalcul (_web_compute_preview), randul cu reviewed=1 si fara erori de + validare reale devine ok (nu mai e blocat de flaguri ambigue). Guard: 404 cross-account (scoping JOIN), 409 batch committed. Raspunde OOB (rand + contoare) + HX-Trigger-After-Settle:inchideModal, @@ -4006,12 +3997,12 @@ async def web_mapare_operatii( request: Request, import_id: int, ) -> HTMLResponse: - """Un singur POST salveaza toate maparile de operatii (US-004). + """Un singur POST salveaza toate maparile de operatii. Primeste perechi (cod_op_service, cod_prestatie) ca liste paralele din un singur