From 1091745789b44a2ac3aacdaf61e9405a1cd2290d Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 10 Jul 2026 12:43:37 +0300 Subject: [PATCH] feat(api): filtru perioada data_de/data_pana pe GET /v1/prezentari data_prestatie traieste in payload_json, nu e coloana SQL: filtrarea se aplica in Python dupa derivarea prezentarii (fetch scope+status fara LIMIT, filtreaza, apoi LIMIT dupa filtrare), acelasi pattern deja folosit de dashboard-ul web (/_fragments/submissions). Extrage helper-ul iso_date_prefix in payload_view.py ca sa fie partajat intre canalul web si cel API (elimina duplicarea). Format invalid pe data_de/data_pana -> 422. Fara parametri, comportamentul raspunsului ramane identic cu inainte. --- app/api/v1/router.py | 62 +++++++- app/payload_view.py | 23 +++ app/web/routes.py | 21 +-- tests/test_api_prezentari_filtru_perioada.py | 156 +++++++++++++++++++ 4 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 tests/test_api_prezentari_filtru_perioada.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 6109b4d..b8cc03e 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -51,7 +51,7 @@ from ...models import ( ValidareResult, ) from ...observ import log_event -from ...payload_view import prezentare_din_payload +from ...payload_view import iso_date_prefix, prezentare_din_payload from ...submissions_admin import ( SubmissionNotFound, SubmissionStateConflict, @@ -503,12 +503,35 @@ def valideaza_prezentari( return ValidareResponse(results=results) +def _valida_data_filtru(nume_param: str, valoare: str | None) -> str | None: + """Valideaza formatul YYYY-MM-DD al unui parametru de filtrare pe perioada. + + None/gol -> None (filtru inactiv). Format invalid -> 422, consecvent cu restul + rutelor API (vezi celelalte `status_code=422` din acest fisier). + """ + v = (valoare or "").strip() + if not v: + return None + try: + datetime.strptime(v, "%Y-%m-%d") + except ValueError: + raise HTTPException( + status_code=422, + detail=f"{nume_param} trebuie sa fie in format YYYY-MM-DD", + ) + return v + + @router.get("/prezentari") def list_prezentari( status: str | None = None, + data_de: str | None = None, + data_pana: str | None = None, limit: int = 100, account_id: int = Depends(resolve_account_id), ) -> dict: + data_de = _valida_data_filtru("data_de", data_de) + data_pana = _valida_data_filtru("data_pana", data_pana) conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) @@ -519,24 +542,49 @@ def list_prezentari( "id, status, id_prezentare, rar_status_code, retry_count, " "created_at, updated_at, payload_json, rar_env, held" ) + where = [scope_sql] + params: list = list(scope_params) if status: + where.append("status=?") + params.append(status) + where_sql = " AND ".join(where) + + # data_prestatie traieste in payload_json, nu e coloana SQL (vezi + # payload_view.prezentare_din_payload) — nu poate intra in clauza WHERE. + # Cu filtru de perioada activ: fetch scope+status FARA LIMIT, filtreaza in + # Python dupa derivarea prezentarii, apoi LIMIT-eaza DUPA filtrare (altfel + # LIMIT SQL ar trunchia gresit inainte de filtru). Acelasi pattern ca + # /_fragments/submissions (dashboard web, app/web/routes.py). + filtru_perioada = bool(data_de or data_pana) + if filtru_perioada: rows = conn.execute( - f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " - f"ORDER BY id DESC LIMIT ?", - scope_params + [status, limit], + f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC", + params, ).fetchall() else: rows = conn.execute( - f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", - scope_params + [limit], + f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?", + params + [limit], ).fetchall() + out = [] for r in rows: d = dict(r) # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); # payload_json brut nu se intoarce in raspuns. - d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None)) + prezentare = prezentare_din_payload(d.pop("payload_json", None)) + if filtru_perioada: + d_prefix = iso_date_prefix(prezentare["data_prestatie"]) + if d_prefix is None: + continue + if data_de and d_prefix < data_de: + continue + if data_pana and d_prefix > data_pana: + continue + d["prezentare"] = prezentare out.append(d) + if filtru_perioada and len(out) >= limit: + break return {"submissions": out} finally: conn.close() diff --git a/app/payload_view.py b/app/payload_view.py index dc77dbd..79eb60b 100644 --- a/app/payload_view.py +++ b/app/payload_view.py @@ -13,11 +13,34 @@ la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor: from __future__ import annotations import json +from datetime import datetime from typing import Any EMPTY = "—" +def iso_date_prefix(value: Any) -> str | None: + """Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel 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. Valori care nu incep cu o data ISO valida + (ex. '05.12.2024') intorc None si sunt excluse din filtru. + + Partajat intre canalul web (dashboard Trimiteri, filtrul data_de/data_pana) si + canalul API (`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua. + """ + s = str(value or "").strip() + if len(s) < 10: + return None + prefix = s[:10] + try: + datetime.strptime(prefix, "%Y-%m-%d") + return prefix + except (ValueError, TypeError): + return None + + def _clean_str(value: Any) -> str: """str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe).""" if value is None: diff --git a/app/web/routes.py b/app/web/routes.py index db0cb6d..449769a 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -27,7 +27,7 @@ from .. import __version__ from .. import errors as _errors from ..auth import rotate_api_key from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS -from ..payload_view import prezentare_din_payload +from ..payload_view import iso_date_prefix as _iso_date_prefix, prezentare_din_payload from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, @@ -1100,25 +1100,6 @@ def fragment_trimiteri_versiune(request: Request) -> JSONResponse: conn.close() -def _iso_date_prefix(value: object) -> str | None: - """Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel 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. 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: - return None - prefix = s[:10] - try: - datetime.strptime(prefix, "%Y-%m-%d") - return prefix - except (ValueError, TypeError): - return None - - # Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana # scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "". _STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping") diff --git a/tests/test_api_prezentari_filtru_perioada.py b/tests/test_api_prezentari_filtru_perioada.py new file mode 100644 index 0000000..950021d --- /dev/null +++ b/tests/test_api_prezentari_filtru_perioada.py @@ -0,0 +1,156 @@ +"""Teste API GET /v1/prezentari — filtrare pe perioada (data_de/data_pana) + obs expus. + +data_prestatie traieste in payload_json (nu e coloana SQL), deci filtrul se aplica +in Python dupa derivarea prezentarii (vezi app/api/v1/router.py:list_prezentari), +acelasi pattern ca /_fragments/submissions (dashboard web). +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _body(**over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez], "raspuns": "complet"} + + +def _trimite(client, data_prestatie: str, **over) -> int: + r = client.post("/v1/prezentari", json=_body(data_prestatie=data_prestatie, **over)) + assert r.status_code == 200, r.text + return r.json()["results"][0]["submission_id"] + + +def test_filtrare_ambii_parametri(client): + id_inainte = _trimite(client, "2026-06-01") + id_in_interval = _trimite(client, "2026-06-15") + id_dupa = _trimite(client, "2026-07-01") + + r = client.get("/v1/prezentari", params={"data_de": "2026-06-10", "data_pana": "2026-06-20"}) + assert r.status_code == 200 + ids = [s["id"] for s in r.json()["submissions"]] + assert ids == [id_in_interval] + assert id_inainte not in ids + assert id_dupa not in ids + + +def test_filtrare_doar_data_de(client): + id_vechi = _trimite(client, "2026-06-01") + id_nou = _trimite(client, "2026-06-20") + + r = client.get("/v1/prezentari", params={"data_de": "2026-06-10"}) + assert r.status_code == 200 + ids = [s["id"] for s in r.json()["submissions"]] + assert id_nou in ids + assert id_vechi not in ids + + +def test_filtrare_doar_data_pana(client): + id_vechi = _trimite(client, "2026-06-01") + id_nou = _trimite(client, "2026-06-20") + + r = client.get("/v1/prezentari", params={"data_pana": "2026-06-10"}) + assert r.status_code == 200 + ids = [s["id"] for s in r.json()["submissions"]] + assert id_vechi in ids + assert id_nou not in ids + + +def test_fara_parametri_comportament_neschimbat(client): + """Fara data_de/data_pana, comportamentul ramane identic (SQL LIMIT, fara filtru Python).""" + id1 = _trimite(client, "2026-06-01") + id2 = _trimite(client, "2026-07-01") + + r = client.get("/v1/prezentari") + assert r.status_code == 200 + ids = [s["id"] for s in r.json()["submissions"]] + assert id1 in ids + assert id2 in ids + + +@pytest.mark.parametrize("param", ["data_de", "data_pana"]) +def test_format_invalid_422(client, param): + r = client.get("/v1/prezentari", params={param: "15-06-2026"}) + assert r.status_code == 422 + + +def test_format_invalid_data_nesexistenta_422(client): + r = client.get("/v1/prezentari", params={"data_de": "2026-02-30"}) + assert r.status_code == 422 + + +def test_interactiune_cu_status(client): + """Filtrul de perioada se combina cu status (AND): doar randuri care satisfac AMBELE.""" + id_queued_in_interval = _trimite(client, "2026-06-15") + # Al doilea rand, status diferit (needs_data via VIN invalid) dar tot in interval. + r = client.post("/v1/prezentari", json=_body( + data_prestatie="2026-06-16", vin="WVWZZZ1OZIQ45678", + )) + assert r.status_code == 200 + id_needs_data = r.json()["results"][0]["submission_id"] + + lista = client.get("/v1/prezentari", params={ + "status": "queued", "data_de": "2026-06-01", "data_pana": "2026-06-30", + }).json()["submissions"] + ids = [s["id"] for s in lista] + assert id_queued_in_interval in ids + assert id_needs_data not in ids + + +def test_interactiune_cu_limit(client): + """LIMIT se aplica DUPA filtrarea pe perioada, nu inainte (altfel ar trunchia gresit).""" + ids_in_interval = [ + _trimite(client, "2026-06-10", vin=f"WVWZZZ1KZAW{i:06d}") for i in range(5) + ] + # Randuri in afara intervalului, mai recente (ORDER BY id DESC) — daca LIMIT s-ar + # aplica INAINTE de filtrare, ar "manca" toate randurile din interval. + for i in range(5): + _trimite(client, "2026-08-01", vin=f"WVWZZZ1KZBW{i:06d}") + + r = client.get("/v1/prezentari", params={ + "data_de": "2026-06-01", "data_pana": "2026-06-30", "limit": 3, + }) + assert r.status_code == 200 + submissions = r.json()["submissions"] + assert len(submissions) == 3 + ids = [s["id"] for s in submissions] + assert all(i in ids_in_interval for i in ids) + + +def test_obs_prezent_in_raspuns(client): + sid = _trimite(client, "2026-06-15", obs="observatie test") + + lista = client.get("/v1/prezentari").json()["submissions"] + rand = next(s for s in lista if s["id"] == sid) + assert rand["prezentare"]["obs"] == "observatie test" + + +def test_obs_gol_cand_lipseste(client): + sid = _trimite(client, "2026-06-15") + + lista = client.get("/v1/prezentari").json()["submissions"] + rand = next(s for s in lista if s["id"] == sid) + assert rand["prezentare"]["obs"] == ""