Catalog central pur app/errors.py ca sursa unica cod->{problema,fix},
consumat de API+UI+worker. Aditiv (field/message pastrate la octet) +
rar_error stocat superset. Scope: fluxul de declarare; login/signup/CSRF
neatinse. labels.parse_erori degradeaza gratios; UI progresiv AA light+dark.
631 teste.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
5.7 KiB
Python
174 lines
5.7 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
|
|
|
|
from app.errors import eroare as _eroare
|
|
|
|
# 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(_eroare(
|
|
"VIN_FORMAT",
|
|
field="vin",
|
|
cauza="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(_eroare(
|
|
"NR_INMATRICULARE_FORMAT",
|
|
field="nr_inmatriculare",
|
|
cauza="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(_eroare(
|
|
"DATA_FORMAT",
|
|
field="data_prestatie",
|
|
cauza="Format data invalid; foloseste YYYY-MM-DD.",
|
|
))
|
|
d = None
|
|
if d is not None:
|
|
if d < MIN_DATA_PRESTATIE:
|
|
errors.append(_eroare(
|
|
"DATA_PREA_VECHE",
|
|
field="data_prestatie",
|
|
cauza="Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
|
|
))
|
|
elif d > today_bucuresti():
|
|
errors.append(_eroare(
|
|
"DATA_VIITOR",
|
|
field="data_prestatie",
|
|
cauza="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(_eroare(
|
|
"ODOMETRU_FINAL_FORMAT",
|
|
field="odometru_final",
|
|
cauza="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(_eroare(
|
|
"ODOMETRU_INITIAL_LIPSA",
|
|
field="odometru_initial",
|
|
cauza="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(_eroare(
|
|
"ODOMETRU_INITIAL_FORMAT",
|
|
field="odometru_initial",
|
|
cauza="odometruInitial trebuie sa fie un numar intreg.",
|
|
))
|
|
elif odo_final is not None and odo_initial > odo_final:
|
|
errors.append(_eroare(
|
|
"ODOMETRU_INITIAL_ORDINE",
|
|
field="odometru_initial",
|
|
cauza="odometruInitial trebuie sa fie <= odometruFinal.",
|
|
))
|
|
|
|
# --- prestatii nevide ---
|
|
if not codes:
|
|
errors.append(_eroare(
|
|
"PRESTATII_GOALE",
|
|
field="prestatii",
|
|
cauza="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(_eroare(
|
|
"B64_INVALID",
|
|
field="b64_image",
|
|
cauza="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
|