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.
This commit is contained in:
@@ -51,7 +51,7 @@ from ...models import (
|
|||||||
ValidareResult,
|
ValidareResult,
|
||||||
)
|
)
|
||||||
from ...observ import log_event
|
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 (
|
from ...submissions_admin import (
|
||||||
SubmissionNotFound,
|
SubmissionNotFound,
|
||||||
SubmissionStateConflict,
|
SubmissionStateConflict,
|
||||||
@@ -503,12 +503,35 @@ def valideaza_prezentari(
|
|||||||
return ValidareResponse(results=results)
|
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")
|
@router.get("/prezentari")
|
||||||
def list_prezentari(
|
def list_prezentari(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
data_de: str | None = None,
|
||||||
|
data_pana: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
account_id: int = Depends(resolve_account_id),
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
data_de = _valida_data_filtru("data_de", data_de)
|
||||||
|
data_pana = _valida_data_filtru("data_pana", data_pana)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
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, "
|
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||||
"created_at, updated_at, payload_json, rar_env, held"
|
"created_at, updated_at, payload_json, rar_env, held"
|
||||||
)
|
)
|
||||||
|
where = [scope_sql]
|
||||||
|
params: list = list(scope_params)
|
||||||
if status:
|
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(
|
rows = conn.execute(
|
||||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
|
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
||||||
f"ORDER BY id DESC LIMIT ?",
|
params,
|
||||||
scope_params + [status, limit],
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?",
|
||||||
scope_params + [limit],
|
params + [limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
||||||
# payload_json brut nu se intoarce in raspuns.
|
# 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)
|
out.append(d)
|
||||||
|
if filtru_perioada and len(out) >= limit:
|
||||||
|
break
|
||||||
return {"submissions": out}
|
return {"submissions": out}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -13,11 +13,34 @@ la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
EMPTY = "—"
|
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:
|
def _clean_str(value: Any) -> str:
|
||||||
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .. import __version__
|
|||||||
from .. import errors as _errors
|
from .. import errors as _errors
|
||||||
from ..auth import rotate_api_key
|
from ..auth import rotate_api_key
|
||||||
from ..plans import effective_tier as _eff_tier, monthly_usage as _monthly_usage, PLANS as _PLANS
|
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 ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from .labels import (
|
from .labels import (
|
||||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||||
@@ -1100,25 +1100,6 @@ def fragment_trimiteri_versiune(request: Request) -> JSONResponse:
|
|||||||
conn.close()
|
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
|
# 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 "".
|
# scurta de pe rand e ne-goala DOAR pe acestea; pe queued/sending/sent e "".
|
||||||
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
_STARI_CU_PROBLEMA = ("error", "needs_data", "needs_mapping")
|
||||||
|
|||||||
156
tests/test_api_prezentari_filtru_perioada.py
Normal file
156
tests/test_api_prezentari_filtru_perioada.py
Normal file
@@ -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"] == ""
|
||||||
Reference in New Issue
Block a user