diff --git a/app/web/routes.py b/app/web/routes.py index 4f4eaa2..b3035af 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -3611,6 +3611,63 @@ async def web_confirma_review( conn.close() +@router.post("/_import/{import_id}/confirma-toate-review", response_class=HTMLResponse) +async def web_confirma_toate_review( + request: Request, + import_id: int, +) -> HTMLResponse: + """Confirma in bloc TOATE randurile needs_review din batch → reviewed=1 (B1). + + Un singur click marcheaza reviewed=1 pe toate randurile cu resolved_status='needs_review' + din batch-ul curent (om in bucla: operatorul confirma explicit intreg lotul, fara + auto-accept). Refoloseste EXACT logica din /confirma-review (reviewed=1 = marcaj separat, + NU camp de continut), aplicata in masa. La recalcul (_web_compute_preview) randurile cu + reviewed=1 si fara erori reale devin ok. + + CSRF + scoped sesiune (404 cross-account) + guard committed (409). O singura recompute + + re-randare #import-section la final (identic cu web_mapare_operatii). + """ + account_id = require_login(request) + conn = get_connection() + try: + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + + # Guard batch: 404 cross-account (nu confirmam existenta), 409 committed. + acct = account_or_default(account_id) + batch = conn.execute( + "SELECT id, status FROM import_batches WHERE id=? AND account_id=?", + (import_id, acct), + ).fetchone() + if not batch: + raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil") + if batch["status"] == "committed": + raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect") + + # Marcheaza reviewed=1 pe toate randurile needs_review ale batch-ului (scoped pe batch, + # deja verificat ca apartine contului). Acelasi marcaj ca /confirma-review, in masa. + cur = conn.execute( + "UPDATE import_rows SET reviewed=1 " + "WHERE batch_id=? AND resolved_status='needs_review'", + (import_id,), + ) + n_confirmate = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 0 + + message = ( + f"Confirmate {n_confirmate} randuri cu valori de verificat." + if n_confirmate + else "Niciun rand de confirmat." + ) + result = _web_compute_preview(conn, import_id, account_id) + if isinstance(result, str): + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, message=message, **result + )) + finally: + conn.close() + + @router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse) async def web_mapare_operatie( request: Request, diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index 1327757..b37940d 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -132,9 +132,24 @@ style="margin-bottom:12px; padding:8px 14px; border-radius:6px; background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card)); border:1px solid var(--warn, #e6b34a); font-size:13px;"> - Randurile cu Verifica valori - nu pleaca la RAR pana le deschizi in modal si confirmi in modal - cu butonul Confirma valorile. +
+ Randurile cu Verifica valori + nu pleaca la RAR pana confirmi valorile. Verifica-le (butonul Confirma valorile + de pe rand sau in modal) sau, daca lotul e in regula, confirma-le pe toate deodata. +
+ {# B1: buton bulk — un click marcheaza reviewed=1 pe toate randurile needs_review. + hx-swap="none": raspunsul re-randeaza #import-section prin outerHTML pe tinta. #} + {% endif %} diff --git a/app/web/templates/_preview_rand.html b/app/web/templates/_preview_rand.html index 277d0ab..f206138 100644 --- a/app/web/templates/_preview_rand.html +++ b/app/web/templates/_preview_rand.html @@ -62,14 +62,31 @@ {{ row.prez.data_prestatie }} {% if status not in ('already_sent', 'duplicate_in_file') %} - +
+ {# B1: confirm rapid per-rand direct din tabel (fara a deschide modalul). + Refoloseste ruta /confirma-review (reviewed=1 pe un singur rand). hx-swap="none": + raspunsul (empty div) NU se insereaza nicaieri; HX-Trigger reincarcaPreview + reincarca sectiunea (contoare + banner corecte). Editarea reseteaza reviewed=0. #} + {% if status == 'needs_review' %} + + {% endif %} + +
{% endif %} @@ -93,9 +110,22 @@ style="margin-bottom:12px; padding:8px 14px; border-radius:6px; background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card)); border:1px solid var(--warn, #e6b34a); font-size:13px;"> - Randurile cu Verifica valori - nu pleaca la RAR pana le deschizi in modal si confirmi in modal - cu butonul Confirma valorile. +
+ Randurile cu Verifica valori + nu pleaca la RAR pana confirmi valorile. Verifica-le (butonul Confirma valorile + de pe rand sau in modal) sau, daca lotul e in regula, confirma-le pe toate deodata. +
+ {% endif %} diff --git a/tests/test_import_review.py b/tests/test_import_review.py index 0a19f94..8a723d9 100644 --- a/tests/test_import_review.py +++ b/tests/test_import_review.py @@ -583,6 +583,228 @@ def test_confirma_review_form_nu_foloseste_hx_swap_none(): ) +def _upload_and_preview_rows(client: TestClient, rows: list[dict]) -> int: + """Upload CSV cu `rows` + salveaza mapare fara format_data -> preview. + + Generalizarea lui `_upload_and_preview_needs_review` pentru mai multe randuri + (fiecare cu data ambigua -> needs_review). Intoarce import_id. + """ + csv_data = _csv_bytes(rows) + csrf = _get_csrf(client) + r = client.post( + "/_import/upload", + files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")}, + data={"csrf_token": csrf}, + ) + assert r.status_code == 200, r.text + m = re.search(r"/_import/(\d+)/mapare-coloane", r.text) + assert m, f"import_id negasit in raspuns: {r.text[:300]}" + iid = int(m.group(1)) + colnames = list(rows[0].keys()) + canons = [_MAP_COLS[c] for c in colnames] + csrf2 = _get_csrf(client) + r2 = client.post(f"/_import/{iid}/mapare-coloane", data={ + "colname": colnames, + "canon": canons, + "format_data": "", # fara format -> ambiguous -> needs_review + "csrf_token": csrf2, + }) + assert r2.status_code == 200, r2.text + return iid + + +# Trei randuri, VIN-uri distincte (nu se dedupe ca duplicat), toate cu data ambigua. +_ROWS_MULTI_NEEDS_REVIEW = [ + {"VIN": "WVWZZZ1KZAW000123", "Nr": "B001TST", "Data": "05.06.2026", "KM": "123456", "Operatie": "OP-1"}, + {"VIN": "WVWZZZ1KZAW000456", "Nr": "B002TST", "Data": "07.08.2026", "KM": "223456", "Operatie": "OP-1"}, + {"VIN": "WVWZZZ1KZAW000789", "Nr": "B003TST", "Data": "09.10.2026", "KM": "323456", "Operatie": "OP-1"}, +] + + +def test_confirma_toate_review_marcheaza_toate(client): + """B1 bulk: POST /_import/{id}/confirma-toate-review seteaza reviewed=1 pe TOATE + randurile needs_review din batch cu un singur click. + + Verifica: + - Raspuns 200 cu preview re-randat (fragment #import-section) + - reviewed=1 in DB pentru toate cele 3 randuri + - dupa recalcul, randurile nu mai sunt needs_review (devin ok -> gata de trimis) + """ + _seed_op1() + iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW) + + # Toate randurile pornesc needs_review (reviewed=0) + for i in range(3): + assert _get_reviewed(iid, i) == 0, f"randul {i} trebuie sa fie reviewed=0 initial" + + csrf = _get_csrf(client) + r = client.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf}) + assert r.status_code == 200, r.text + # Raspunsul re-randeaza sectiunea de preview + assert "import-section" in r.text, "bulk trebuie sa re-randeze #import-section" + + # Toate randurile confirmate in DB + for i in range(3): + assert _get_reviewed(iid, i) == 1, \ + f"randul {i} trebuie sa fie reviewed=1 dupa confirmarea in bloc" + + # Recalcul: niciun rand nu mai e needs_review (toate ok) + r2 = client.get(f"/_import/{iid}/preview") + assert r2.status_code == 200, r2.text + assert 'data-status="needs_review"' not in r2.text, \ + "dupa confirmarea in bloc, niciun rand nu mai trebuie sa fie needs_review" + + +def test_confirma_toate_review_guard_committed_409(client): + """POST confirma-toate-review pe batch deja comis -> 409.""" + _seed_op1() + iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW) + + from app.db import get_connection + conn = get_connection() + try: + conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,)) + conn.commit() + finally: + conn.close() + + csrf = _get_csrf(client) + r = client.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf}) + assert r.status_code == 409, \ + f"confirma-toate-review pe batch committed trebuie sa returneze 409, got {r.status_code}" + + +def test_confirma_toate_review_scoped_404_alt_cont(): + """POST confirma-toate-review pe batch-ul altui cont -> 404, iar randurile + contului A raman needs_review (reviewed=0): bulk-ul altui cont NU le atinge.""" + tmp = tempfile.mkdtemp() + env_patch = { + "AUTOPASS_DB_PATH": os.path.join(tmp, "scope_bulk.db"), + "AUTOPASS_WEB_AUTH_REQUIRED": "true", + } + for k, v in env_patch.items(): + os.environ[k] = v + from app.config import get_settings + get_settings.cache_clear() + from app.crypto import reset_cache + reset_cache() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + + try: + with TestClient(app, follow_redirects=False) as c: + from app.db import get_connection + from app.accounts import create_account + from app.users import create_user + conn = get_connection() + try: + acct1 = create_account(conn, "Firma A", active=True) + create_user(conn, acct1, "userA@test.com", "parola123secure") + acct2 = create_account(conn, "Firma B", active=True) + create_user(conn, acct2, "userB@test.com", "parola123secure") + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) " + "VALUES ('R-FRANE','Reparatie frane')" + ) + conn.execute( + "INSERT OR IGNORE INTO operations_mapping " + "(account_id, cod_op_service, cod_prestatie, auto_send) " + "VALUES (?, 'OP-1', 'R-FRANE', 1)", (acct1,) + ) + conn.commit() + finally: + conn.close() + + def _login(client, email, pwd="parola123secure"): + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m + client.post("/login", data={"email": email, "parola": pwd, "csrf_token": m.group(1)}) + + def _csrf(): + r = c.get("/") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text) + return m.group(1) if m else "" + + # userA creeaza batch cu randuri needs_review + _login(c, "userA@test.com") + rows = _ROWS_MULTI_NEEDS_REVIEW + csrf = _csrf() + r = c.post( + "/_import/upload", + files={"file": ("test.csv", io.BytesIO(_csv_bytes(rows)), "text/csv")}, + data={"csrf_token": csrf}, + ) + assert r.status_code == 200 + m = re.search(r"/_import/(\d+)/mapare-coloane", r.text) + assert m + iid = int(m.group(1)) + colnames = list(rows[0].keys()) + canons = [_MAP_COLS[c] for c in colnames] + csrf2 = _csrf() + c.post(f"/_import/{iid}/mapare-coloane", data={ + "colname": colnames, "canon": canons, "format_data": "", "csrf_token": csrf2, + }) + + # userB incearca bulk pe batch-ul lui A -> 404 + _login(c, "userB@test.com") + csrf3 = _csrf() + r2 = c.post(f"/_import/{iid}/confirma-toate-review", data={"csrf_token": csrf3}) + assert r2.status_code == 404, \ + f"confirma-toate-review cross-account trebuie sa returneze 404, got {r2.status_code}" + + # Randurile lui A raman needs_review (reviewed=0): nu au fost atinse. + conn = get_connection() + try: + n = conn.execute( + "SELECT COUNT(*) AS c FROM import_rows WHERE batch_id=? AND reviewed=1", + (iid,), + ).fetchone()["c"] + assert n == 0, "bulk-ul altui cont NU trebuie sa marcheze reviewed pe randurile lui A" + finally: + conn.close() + finally: + for k in env_patch: + if k in os.environ: + del os.environ[k] + ratelimit._hits.clear() + get_settings.cache_clear() + reset_cache() + + +def test_confirm_rapid_buton_prezent_in_tabel(client): + """B1 quick: randul needs_review are butonul de confirm rapid direct in tabel, + care POST-eaza pe ruta /confirma-review (fara a deschide modalul).""" + _seed_op1() + iid = _upload_and_preview_needs_review(client) + + r = client.get(f"/_import/{iid}/preview") + assert r.status_code == 200, r.text + html = r.text + assert "btn-confirma-rapid" in html, \ + "randul needs_review trebuie sa aiba butonul de confirm rapid in tabel" + assert f"/_import/{iid}/rand/0/confirma-review" in html, \ + "butonul de confirm rapid trebuie sa POST-eze pe ruta /confirma-review" + + +def test_confirm_rapid_per_rand_seteaza_reviewed(client): + """B1 quick: POST /confirma-review din tabel (fara modal) seteaza reviewed=1 pe un + singur rand si NU atinge celelalte randuri needs_review din batch.""" + _seed_op1() + iid = _upload_and_preview_rows(client, _ROWS_MULTI_NEEDS_REVIEW) + + csrf = _get_csrf(client) + r = client.post(f"/_import/{iid}/rand/1/confirma-review", data={"csrf_token": csrf}) + assert r.status_code == 200, r.text + + assert _get_reviewed(iid, 1) == 1, "randul confirmat rapid trebuie sa fie reviewed=1" + assert _get_reviewed(iid, 0) == 0, "confirmul per-rand NU trebuie sa atinga alte randuri" + assert _get_reviewed(iid, 2) == 0, "confirmul per-rand NU trebuie sa atinga alte randuri" + + def test_confirma_review_cere_reincarcarea_preview(client): """Contractul nou (dogfood 5.13): confirma-review NU mai depinde de scriptul updateN din payload (care, cu OOB pe rupt, lasa randul stale). Acum cere reincarcaPreview,