Files
rar-autopass/app/validation.py
Claude Agent 2117ab5c1e feat(T3): validare completa prezentari + 29 teste
- app/validation.py: reguli de continut (VIN ^[A-HJ-NPR-Z0-9]{17}$ fara O/I/Q,
  nrInm ^[A-Z0-9]{1,10}$, dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti,
  R-ODO/I-ODO -> odometruInitial obligatoriu, odometruInitial<=odometruFinal,
  odometruFinal numeric, prestatii nevide, b64Image base64 valid)
- erori structurate {field, message} (aceeasi forma ca raspunsul RAR), fara exceptii
- modele Pydantic: normalizare strip/upper pe vin/nrInm/coduri
- router /v1/prezentari: validare inainte de enqueue; esec continut -> needs_data
  (tinut, vizibil in dashboard cu motiv), NU 422; JSON malformat -> 422 (shape)
- tests/: 29 teste (per regula + rutare API + idempotenta)

Verify: pytest 29 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:49:20 +00:00

137 lines
5.0 KiB
Python

"""Validare de domeniu pentru prezentari (T3).
Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE
de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE
(Pydantic, da 422 la JSON malformat), aceasta e validare de CONTINUT: esecurile NU
resping cererea, ci marcheaza submission-ul `needs_data` (plan.md sect. 3 — masina de
stari + failure registry). Asa apar in dashboard cu motiv, corectabile.
Functiile sunt pure (dict -> listă erori), unit-testabile fara DB/HTTP.
Erorile au forma {field, message} — aceeasi ca raspunsul de eroare RAR.
"""
from __future__ import annotations
import base64
import re
from datetime import date
from zoneinfo import ZoneInfo
# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract).
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
# Numar inmatriculare: max 10, litere + cifre majuscule.
NRINM_RE = re.compile(r"^[A-Z0-9]{1,10}$")
# Coduri care fac odometruInitial obligatoriu.
ODOMETER_CODES = {"R-ODO", "I-ODO"}
# Interval dataPrestatie.
MIN_DATA_PRESTATIE = date(2024, 12, 1)
TZ_BUCURESTI = ZoneInfo("Europe/Bucharest")
def _norm(value: object) -> str:
return str(value or "").strip().upper()
def _codes(prestatii: list | None) -> list[str]:
out: list[str] = []
for p in prestatii or []:
cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)
if cod:
out.append(str(cod).strip().upper())
return out
def _parse_int(value: object) -> int | None:
s = str(value or "").strip()
if s.isdigit():
return int(s)
return None
def today_bucuresti() -> date:
from datetime import datetime
return datetime.now(TZ_BUCURESTI).date()
def validate_prezentare(content: dict) -> list[dict]:
"""Intoarce lista erorilor de continut [{field, message}]. Goala = valid.
`content` = PrezentareIn.model_dump() (campuri snake_case interne).
"""
errors: list[dict] = []
# --- VIN ---
vin = _norm(content.get("vin"))
if not VIN_RE.match(vin):
errors.append({
"field": "vin",
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
})
# --- nrInmatriculare ---
nrinm = _norm(content.get("nr_inmatriculare"))
if not NRINM_RE.match(nrinm):
errors.append({
"field": "nr_inmatriculare",
"message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
})
# --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti ---
raw_data = str(content.get("data_prestatie") or "").strip()
try:
d = date.fromisoformat(raw_data)
except ValueError:
errors.append({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."})
d = None
if d is not None:
if d < MIN_DATA_PRESTATIE:
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."})
elif d > today_bucuresti():
errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."})
# --- odometruFinal (string numeric) ---
odo_final = _parse_int(content.get("odometru_final"))
if odo_final is None:
errors.append({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."})
# --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal ---
codes = _codes(content.get("prestatii"))
needs_initial = bool(set(codes) & ODOMETER_CODES)
raw_initial = content.get("odometru_initial")
has_initial = str(raw_initial or "").strip() != ""
if needs_initial and not has_initial:
errors.append({
"field": "odometru_initial",
"message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.",
})
if has_initial:
odo_initial = _parse_int(raw_initial)
if odo_initial is None:
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."})
elif odo_final is not None and odo_initial > odo_final:
errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."})
# --- prestatii nevide ---
if not codes:
errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."})
# --- b64Image: optional, dar daca e prezent trebuie base64 valid ---
b64 = content.get("b64_image")
if b64:
if not _is_valid_base64(str(b64)):
errors.append({"field": "b64_image", "message": "b64Image nu este base64 valid."})
return errors
def _is_valid_base64(value: str) -> bool:
s = value.strip()
if not s:
return False
try:
base64.b64decode(s, validate=True)
return True
except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
return False