Compare commits
5 Commits
f0786051f5
...
36ec50d667
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ec50d667 | ||
|
|
1fbd894329 | ||
|
|
14e1c463f0 | ||
|
|
b48501d8e4 | ||
|
|
ae7960294f |
@@ -39,9 +39,10 @@ def create_account(
|
||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||
cui = _norm_cui(cui)
|
||||
try:
|
||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||
cur = conn.execute(
|
||||
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)",
|
||||
(name, cui, 1 if active else 0),
|
||||
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
|
||||
(name, cui, 1 if active else 0, "active" if active else "pending"),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||
@@ -55,16 +56,72 @@ def create_account(
|
||||
|
||||
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
|
||||
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
||||
Cont inexistent -> ValueError."""
|
||||
Cont inexistent -> ValueError.
|
||||
|
||||
Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active',
|
||||
dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere
|
||||
foloseste `set_status`/`delete_account`.
|
||||
"""
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id))
|
||||
conn.execute(
|
||||
"UPDATE accounts SET active=?, status=? WHERE id=?",
|
||||
(1 if active else 0, "active" if active else "pending", account_id),
|
||||
)
|
||||
|
||||
|
||||
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
|
||||
# retentie); restul sunt reversibile.
|
||||
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
|
||||
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
|
||||
_PROTECTED_ACCOUNT_ID = 1
|
||||
|
||||
|
||||
def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
|
||||
"""Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active`
|
||||
(active=1 doar pentru 'active', altfel 0).
|
||||
|
||||
Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError.
|
||||
Status invalid sau cont inexistent -> ValueError.
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"status invalid: {status}")
|
||||
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
if account_id == _PROTECTED_ACCOUNT_ID and status != "active":
|
||||
raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).")
|
||||
conn.execute(
|
||||
"UPDATE accounts SET active=?, status=? WHERE id=?",
|
||||
(1 if status == "active" else 0, status, account_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
|
||||
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
|
||||
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
|
||||
revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic
|
||||
`ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat.
|
||||
|
||||
Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram
|
||||
tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches,
|
||||
NU acest tombstone — de aceea purjam PII aici, la momentul stergerii."""
|
||||
set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
|
||||
(account_id,),
|
||||
)
|
||||
|
||||
|
||||
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id."""
|
||||
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||
(stergere soft -> invizibile in panou)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id"
|
||||
"SELECT id, name, cui, active, status, created_at FROM accounts "
|
||||
"WHERE status != 'deleted' ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -30,6 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ... import errors
|
||||
from ...auth import resolve_account_id
|
||||
from ...crypto import decrypt_creds, encrypt_creds
|
||||
from ...db import get_connection
|
||||
@@ -326,7 +327,15 @@ def apply_row_override(
|
||||
dec = decrypt_creds(row["oj"])
|
||||
if dec is None:
|
||||
# Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala.
|
||||
raise HTTPException(status_code=422, detail="override curent ilizibil; editare anulata")
|
||||
_oi_msg = "override curent ilizibil; editare anulata"
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "override_ilizibil",
|
||||
"message": _oi_msg,
|
||||
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=_oi_msg),
|
||||
},
|
||||
)
|
||||
current = dec
|
||||
|
||||
new_override = _merge_override(current, fields)
|
||||
@@ -406,6 +415,7 @@ async def upload_import(
|
||||
"error": "multiple_sheets",
|
||||
"message": str(ms),
|
||||
"sheets": ms.sheet_names,
|
||||
**errors.eroare("IMPORT_MULTIPLE_SHEETS", cauza=str(ms)),
|
||||
},
|
||||
)
|
||||
except FileTooLarge as e:
|
||||
@@ -414,6 +424,7 @@ async def upload_import(
|
||||
detail={
|
||||
"error": "file_too_large",
|
||||
"message": str(e),
|
||||
**errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)),
|
||||
},
|
||||
)
|
||||
except HeaderError as e:
|
||||
@@ -423,23 +434,28 @@ async def upload_import(
|
||||
"error": "header_error",
|
||||
"message": str(e),
|
||||
"found": e.found,
|
||||
**errors.eroare("IMPORT_ANTET_NECLAR", cauza=str(e)),
|
||||
},
|
||||
)
|
||||
except UnicodeDecodeError as e:
|
||||
_enc_msg = f"Encoding nesuportat: {e.reason}"
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "encoding_error",
|
||||
"message": f"Encoding nesuportat: {e.reason}",
|
||||
"message": _enc_msg,
|
||||
**errors.eroare("IMPORT_ENCODING", cauza=_enc_msg),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
# Fisier corupt (BadZipFile, InvalidFileException, etc.)
|
||||
_inv_msg = f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "invalid_file",
|
||||
"message": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
|
||||
"message": _inv_msg,
|
||||
**errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=_inv_msg),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -713,11 +729,13 @@ def preview_import(
|
||||
).fetchone()
|
||||
|
||||
if not mapping_row:
|
||||
_ncm_msg = "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "no_column_mapping",
|
||||
"message": "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea.",
|
||||
"message": _ncm_msg,
|
||||
**errors.eroare("IMPORT_FARA_MAPARE_COLOANE", cauza=_ncm_msg),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -971,12 +989,14 @@ def commit_import(
|
||||
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
|
||||
n_total_ok = len(ok_rows)
|
||||
if req.n_confirmat != n_total_ok:
|
||||
_cg_msg = f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul."
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"error": "confirmare_gresita",
|
||||
"message": f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul.",
|
||||
"message": _cg_msg,
|
||||
"n_ok": n_total_ok,
|
||||
**errors.eroare("IMPORT_CONFIRMARE_GRESITA", cauza=_cg_msg),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -22,20 +22,26 @@ from pydantic import BaseModel, Field
|
||||
from ...auth import resolve_account_id
|
||||
from ...crypto import encrypt_creds
|
||||
from ...db import get_connection
|
||||
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
||||
from ...errors import eroare as err_eroare
|
||||
from ...idempotency import build_key, canonicalize_row
|
||||
from ...mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
has_no_auto_send,
|
||||
classify_prezentare,
|
||||
load_mapping_meta,
|
||||
pending_unmapped,
|
||||
reresolve_account,
|
||||
resolve_prestatii,
|
||||
save_mapping,
|
||||
)
|
||||
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||
from ...models import (
|
||||
PrezentareRequest,
|
||||
PrezentariResponse,
|
||||
SubmissionResult,
|
||||
ValidarePrezentariRequest,
|
||||
ValidareResponse,
|
||||
ValidareResult,
|
||||
)
|
||||
from ...payload_view import prezentare_din_payload
|
||||
from ...validation import validate_prezentare
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
@@ -94,43 +100,58 @@ def create_prezentari(
|
||||
)
|
||||
continue
|
||||
|
||||
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
|
||||
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
|
||||
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
|
||||
# validarea T3 + payload builder + worker raman code-driven.
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||
content["prestatii"] = resolved
|
||||
|
||||
if unmapped:
|
||||
status = "needs_mapping"
|
||||
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
|
||||
else:
|
||||
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
|
||||
elif has_no_auto_send(resolved, mapping_meta):
|
||||
# T6/OV-1: cod rezolvat cu auto_send=0 -> nu trimite automat.
|
||||
# Randul ramane 'needs_mapping' pana userul confirma manual (sau comuta auto_send=1).
|
||||
status = "needs_mapping"
|
||||
rar_error = json.dumps(
|
||||
{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
status, rar_error = "queued", None
|
||||
|
||||
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
|
||||
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
||||
cl = classify_prezentare(content, mapping, mapping_meta)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc),
|
||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
||||
)
|
||||
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
|
||||
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"]))
|
||||
finally:
|
||||
conn.close()
|
||||
return PrezentariResponse(results=results)
|
||||
|
||||
|
||||
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
|
||||
def valideaza_prezentari(
|
||||
req: ValidarePrezentariRequest,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> ValidareResponse:
|
||||
"""Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare.
|
||||
|
||||
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
|
||||
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
|
||||
payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
results: list[ValidareResult] = []
|
||||
try:
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
for i, prez in enumerate(req.prezentari):
|
||||
content = prez.model_dump()
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||
nemapate = [
|
||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}
|
||||
for u in res["unmapped"]
|
||||
]
|
||||
results.append(ValidareResult(
|
||||
index=i,
|
||||
valid=(res["status"] == "queued"),
|
||||
status_estimat=res["status"],
|
||||
erori=res["errors"],
|
||||
nemapate=nemapate,
|
||||
prestatii_rezolvate=res["resolved"],
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
return ValidareResponse(results=results)
|
||||
|
||||
|
||||
@router.get("/prezentari")
|
||||
def list_prezentari(
|
||||
status: str | None = None,
|
||||
|
||||
12
app/db.py
12
app/db.py
@@ -63,6 +63,18 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if "active" not in acc_cols:
|
||||
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||
acc_cols.add("active")
|
||||
if "status" not in acc_cols:
|
||||
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
|
||||
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
|
||||
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
|
||||
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
|
||||
)
|
||||
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||
|
||||
203
app/errors.py
Normal file
203
app/errors.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Catalog central de erori AutoPass (PRD 5.4).
|
||||
|
||||
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
|
||||
cu un helper care construieste obiectul de eroare pe 3 niveluri:
|
||||
- nivel 1 (tehnic): `cod` + `cauza` — ce s-a intamplat exact
|
||||
- nivel 2 (utilizator): `problema` — descriere scurta, inteligibila
|
||||
- nivel 3 (actiune): `fix` — ce trebuie facut pentru a remedia
|
||||
|
||||
Modul PUR — fara import DB sau HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CATALOG
|
||||
# cheie = cod (string), valoare = {"problema": str, "fix": str}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CATALOG: dict[str, dict[str, str]] = {
|
||||
"VIN_FORMAT": {
|
||||
"problema": "VIN invalid",
|
||||
"fix": (
|
||||
"Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere"
|
||||
" majuscule, fara spatii si fara literele O, I, Q."
|
||||
),
|
||||
},
|
||||
"NR_INMATRICULARE_FORMAT": {
|
||||
"problema": "Numar de inmatriculare invalid",
|
||||
"fix": (
|
||||
"Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii"
|
||||
" sau cratima (ex. B123ABC)."
|
||||
),
|
||||
},
|
||||
"DATA_FORMAT": {
|
||||
"problema": "Data prestatiei in format gresit",
|
||||
"fix": "Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22).",
|
||||
},
|
||||
"DATA_PREA_VECHE": {
|
||||
"problema": "Data prestatiei prea veche",
|
||||
"fix": (
|
||||
"RAR accepta prestatii doar incepand cu 01.12.2024;"
|
||||
" verifica data prestatiei."
|
||||
),
|
||||
},
|
||||
"DATA_VIITOR": {
|
||||
"problema": "Data prestatiei in viitor",
|
||||
"fix": "Data prestatiei nu poate fi dupa ziua de azi; corecteaza data.",
|
||||
},
|
||||
"ODOMETRU_FINAL_FORMAT": {
|
||||
"problema": "Odometru final invalid",
|
||||
"fix": (
|
||||
"Scrie kilometrajul final ca numar intreg, fara zecimale sau text"
|
||||
" (ex. 145000)."
|
||||
),
|
||||
},
|
||||
"ODOMETRU_INITIAL_LIPSA": {
|
||||
"problema": "Lipseste odometrul initial",
|
||||
"fix": (
|
||||
"Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l."
|
||||
),
|
||||
},
|
||||
"ODOMETRU_INITIAL_FORMAT": {
|
||||
"problema": "Odometru initial invalid",
|
||||
"fix": (
|
||||
"Scrie kilometrajul initial ca numar intreg, fara zecimale sau text."
|
||||
),
|
||||
},
|
||||
"ODOMETRU_INITIAL_ORDINE": {
|
||||
"problema": "Odometru initial mai mare decat finalul",
|
||||
"fix": (
|
||||
"Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final;"
|
||||
" verifica cele doua valori."
|
||||
),
|
||||
},
|
||||
"PRESTATII_GOALE": {
|
||||
"problema": "Nicio prestatie",
|
||||
"fix": "Adauga cel putin o prestatie cu cod RAR valid.",
|
||||
},
|
||||
"B64_INVALID": {
|
||||
"problema": "Imaginea nu este base64 valid",
|
||||
"fix": (
|
||||
"Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine."
|
||||
),
|
||||
},
|
||||
"COD_NEMAPAT": {
|
||||
"problema": "Lipseste codul RAR al operatiei",
|
||||
"fix": (
|
||||
"Alege codul RAR pentru aceasta operatie in tab-ul Mapari"
|
||||
" (ai sugestii automate)."
|
||||
),
|
||||
},
|
||||
"AUTO_SEND_OPRIT": {
|
||||
"problema": "Necesita confirmare manuala",
|
||||
"fix": (
|
||||
"Codul e mapat cu trimitere automata oprita; verifica randul si"
|
||||
" pune-l manual in coada."
|
||||
),
|
||||
},
|
||||
"RAR_VALIDARE": {
|
||||
"problema": "RAR a respins prezentarea",
|
||||
"fix": (
|
||||
"Corecteaza campul semnalat de RAR (vezi cauza) si reincearca;"
|
||||
" detaliile exacte sunt in mesajul tehnic RAR."
|
||||
),
|
||||
},
|
||||
"RAR_CREDS_INVALIDE": {
|
||||
"problema": "Credentiale RAR invalide",
|
||||
"fix": (
|
||||
"Verifica email-ul si parola contului RAR in tab-ul Cont;"
|
||||
" trimiterea nu se reincearca automat la credentiale gresite."
|
||||
),
|
||||
},
|
||||
"IMPORT_FISIER_PREA_MARE": {
|
||||
"problema": "Fisier prea mare",
|
||||
"fix": (
|
||||
"Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand."
|
||||
),
|
||||
},
|
||||
"IMPORT_ANTET_NECLAR": {
|
||||
"problema": "Antet de coloane neclar",
|
||||
"fix": (
|
||||
"Asigura-te ca primul rand contine numele coloanelor"
|
||||
" (ex. VIN, Numar, Data)."
|
||||
),
|
||||
},
|
||||
"IMPORT_ENCODING": {
|
||||
"problema": "Codare de caractere nesuportata",
|
||||
"fix": "Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca.",
|
||||
},
|
||||
"IMPORT_FISIER_NERECUNOSCUT": {
|
||||
"problema": "Fisier nerecunoscut",
|
||||
"fix": "Incarca un fisier .xlsx sau .csv valid.",
|
||||
},
|
||||
"IMPORT_MULTIPLE_SHEETS": {
|
||||
"problema": "Mai multe foi in fisier",
|
||||
"fix": "Pastreaza datele intr-o singura foaie sau alege foaia de import.",
|
||||
},
|
||||
"IMPORT_FARA_MAPARE_COLOANE": {
|
||||
"problema": "Coloanele nu sunt mapate",
|
||||
"fix": (
|
||||
"Mapeaza intai coloanele fisierului la campurile cerute, apoi continua."
|
||||
),
|
||||
},
|
||||
"IMPORT_CONFIRMARE_GRESITA": {
|
||||
"problema": "Numar confirmat gresit",
|
||||
"fix": (
|
||||
"Numarul confirmat difera de randurile gata de trimis;"
|
||||
" verifica preview-ul si reconfirma."
|
||||
),
|
||||
},
|
||||
"IMPORT_OVERRIDE_ILIZIBIL": {
|
||||
"problema": "Editarea anterioara nu se poate citi",
|
||||
"fix": (
|
||||
"Editarea salvata este ilizibila (probabil cheia s-a schimbat);"
|
||||
" reediteaza randul."
|
||||
),
|
||||
},
|
||||
"COLOANE_FORMAT_JSON": {
|
||||
"problema": "Format de coloane (JSON) invalid",
|
||||
"fix": (
|
||||
"Verifica sintaxa JSON a maparii de coloane"
|
||||
" (ghilimele duble, acolade inchise corect)."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# eroare()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eroare(
|
||||
cod: str,
|
||||
*,
|
||||
field: str | None = None,
|
||||
cauza: str | None = None,
|
||||
) -> dict:
|
||||
"""Construieste un obiect de eroare pe 3 niveluri din CATALOG.
|
||||
|
||||
Parametri
|
||||
---------
|
||||
cod: Codul de eroare (cheie in CATALOG). Ridica KeyError daca absent.
|
||||
field: Campul care a generat eroarea (optional, pentru context).
|
||||
cauza: Descrierea tehnica a erorii concrete (optional).
|
||||
Daca lipseste, `cauza` si `message` preiau valoarea `problema` din catalog.
|
||||
|
||||
Returneaza
|
||||
----------
|
||||
dict cu exact cheile: field, cod, problema, cauza, fix, message.
|
||||
"""
|
||||
entry = CATALOG[cod] # ridica KeyError daca cod absent
|
||||
problema = entry["problema"]
|
||||
fix = entry["fix"]
|
||||
cauza_efectiva = cauza if cauza is not None else problema
|
||||
message = cauza if cauza is not None else problema
|
||||
return {
|
||||
"field": field,
|
||||
"cod": cod,
|
||||
"problema": problema,
|
||||
"cauza": cauza_efectiva,
|
||||
"fix": fix,
|
||||
"message": message,
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from typing import Any
|
||||
|
||||
from rapidfuzz import fuzz, process
|
||||
|
||||
from . import errors as err_mod
|
||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||
from .validation import validate_prezentare
|
||||
|
||||
@@ -217,6 +218,66 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||
}
|
||||
|
||||
|
||||
def classify_prezentare(
|
||||
content: dict,
|
||||
mapping: dict[str, str],
|
||||
mapping_meta: dict[str, dict],
|
||||
) -> dict:
|
||||
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
|
||||
|
||||
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
|
||||
a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2).
|
||||
|
||||
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
|
||||
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
|
||||
"""
|
||||
from .idempotency import canonicalize_row # import local: evita circular (mapping <- idempotency)
|
||||
|
||||
c = dict(content)
|
||||
canon = canonicalize_row(c)
|
||||
c.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping)
|
||||
c["prestatii"] = resolved
|
||||
|
||||
if unmapped:
|
||||
status = "needs_mapping"
|
||||
coduri = ", ".join((u.get("cod_op_service") or "") for u in unmapped)
|
||||
rar_error = json.dumps(
|
||||
{"unmapped": unmapped, **err_mod.eroare("COD_NEMAPAT", cauza=f"Coduri fara mapare RAR: {coduri}")},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
errors: list[dict] = []
|
||||
else:
|
||||
errors = validate_prezentare(c)
|
||||
if errors:
|
||||
status = "needs_data"
|
||||
rar_error = json.dumps(errors, ensure_ascii=False)
|
||||
elif has_no_auto_send(resolved, mapping_meta):
|
||||
status = "needs_mapping"
|
||||
mesaj = "cod mapat cu auto_send=0; review manual inainte de trimitere"
|
||||
rar_error = json.dumps(
|
||||
{"auto_send": mesaj, **err_mod.eroare("AUTO_SEND_OPRIT", cauza=mesaj)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
status = "queued"
|
||||
rar_error = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"rar_error": rar_error,
|
||||
"resolved": resolved,
|
||||
"unmapped": unmapped,
|
||||
"errors": errors,
|
||||
"content": c,
|
||||
}
|
||||
|
||||
|
||||
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
|
||||
"""Verifica daca vreun item rezolvat via mapping are auto_send=0.
|
||||
|
||||
|
||||
@@ -95,3 +95,25 @@ class SubmissionResult(BaseModel):
|
||||
|
||||
class PrezentariResponse(BaseModel):
|
||||
results: list[SubmissionResult]
|
||||
|
||||
|
||||
class ValidarePrezentariRequest(BaseModel):
|
||||
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2)."""
|
||||
|
||||
rar_credentials: RarCredentials | None = None
|
||||
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ValidareResult(BaseModel):
|
||||
"""Verdictul dry-run per prezentare."""
|
||||
|
||||
index: int
|
||||
valid: bool
|
||||
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
|
||||
erori: list[dict] = []
|
||||
nemapate: list[dict] = []
|
||||
prestatii_rezolvate: list[dict] = []
|
||||
|
||||
|
||||
class ValidareResponse(BaseModel):
|
||||
results: list[ValidareResult]
|
||||
|
||||
@@ -11,6 +11,13 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
name TEXT NOT NULL,
|
||||
cui TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
|
||||
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
|
||||
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
|
||||
-- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste,
|
||||
-- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere,
|
||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -17,6 +17,8 @@ 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.
|
||||
@@ -64,36 +66,54 @@ def validate_prezentare(content: dict) -> 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.",
|
||||
})
|
||||
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({
|
||||
"field": "nr_inmatriculare",
|
||||
"message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.",
|
||||
})
|
||||
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({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."})
|
||||
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({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."})
|
||||
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({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."})
|
||||
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({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."})
|
||||
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"))
|
||||
@@ -101,26 +121,43 @@ def validate_prezentare(content: dict) -> list[dict]:
|
||||
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.",
|
||||
})
|
||||
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({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."})
|
||||
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({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."})
|
||||
errors.append(_eroare(
|
||||
"ODOMETRU_INITIAL_ORDINE",
|
||||
field="odometru_initial",
|
||||
cauza="odometruInitial trebuie sa fie <= odometruFinal.",
|
||||
))
|
||||
|
||||
# --- prestatii nevide ---
|
||||
if not codes:
|
||||
errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."})
|
||||
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({"field": "b64_image", "message": "b64Image nu este base64 valid."})
|
||||
errors.append(_eroare(
|
||||
"B64_INVALID",
|
||||
field="b64_image",
|
||||
cauza="b64Image nu este base64 valid.",
|
||||
))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import list_accounts, set_active
|
||||
from ..accounts import list_accounts, set_active, set_status, delete_account
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
@@ -49,16 +49,20 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
|
||||
emails = _emails_by_account(conn)
|
||||
for acct in accounts:
|
||||
acct["email"] = emails.get(acct["id"])
|
||||
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
|
||||
active = [a for a in accounts if a["active"] and a["id"] != 1]
|
||||
default = next((a for a in accounts if a["id"] == 1), None)
|
||||
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
|
||||
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
|
||||
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
|
||||
suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1]
|
||||
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
pending=pending,
|
||||
active=active,
|
||||
default_account=default,
|
||||
suspended=suspended,
|
||||
error=error,
|
||||
is_authenticated=True,
|
||||
is_admin=True,
|
||||
), status_code=status_code)
|
||||
|
||||
|
||||
@@ -74,28 +78,66 @@ async def admin_get(request: Request):
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
||||
async def admin_activate(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
|
||||
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
||||
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
|
||||
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
|
||||
`action`: activate | block | archive | delete."""
|
||||
for aid in ids:
|
||||
try:
|
||||
if action == "activate":
|
||||
set_status(conn, aid, "active")
|
||||
elif action == "block":
|
||||
set_status(conn, aid, "blocked")
|
||||
elif action == "archive":
|
||||
set_status(conn, aid, "archived")
|
||||
elif action == "delete":
|
||||
delete_account(conn, aid)
|
||||
except ValueError:
|
||||
continue # cont de sistem / inexistent -> sarit
|
||||
|
||||
|
||||
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
|
||||
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
|
||||
Evita 4 handlere copy-paste care difera doar prin verb."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
set_active(conn, account_id, True)
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
_apply_lifecycle(conn, account_id, action)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
||||
async def admin_activate(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Activeaza unul sau mai multe conturi (bulk). PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "activate")
|
||||
|
||||
|
||||
@router.post("/admin/block", response_class=HTMLResponse)
|
||||
async def admin_block(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "block")
|
||||
|
||||
|
||||
@router.post("/admin/archive", response_class=HTMLResponse)
|
||||
async def admin_archive(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "archive")
|
||||
|
||||
|
||||
@router.post("/admin/delete", response_class=HTMLResponse)
|
||||
async def admin_delete(request: Request, account_id: list[int] = Form(...),
|
||||
csrf_token: str = Form(default="")):
|
||||
"""Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303."""
|
||||
return _lifecycle_route(request, account_id, csrf_token, "delete")
|
||||
|
||||
|
||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||
async def admin_deactivate(
|
||||
request: Request,
|
||||
|
||||
@@ -178,6 +178,8 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
||||
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
|
||||
if "auto_send" in data:
|
||||
return "Necesita confirmare manuala (auto-send oprit pentru cod)"
|
||||
if "problema" in data:
|
||||
return str(data.get("problema") or "")[:200]
|
||||
parti = [f"{k}: {v}" for k, v in data.items()]
|
||||
return "; ".join(parti)[:200]
|
||||
|
||||
@@ -195,6 +197,102 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
||||
return str(data)[:160]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_erori(rar_error: object) -> list[dict]:
|
||||
"""Transforma `rar_error` (JSON stocat) intr-o lista de erori 3-niveluri.
|
||||
|
||||
Fiecare element al listei are cheile: problema, cauza, fix, field (sau None).
|
||||
Functie PURA — nu arunca niciodata exceptii; degradeaza gratios pe orice forma.
|
||||
|
||||
Forme recunoscute:
|
||||
- None / "" / falsy -> lista goala []
|
||||
- array imbogatit (au cod sau problema) -> un element per eroare
|
||||
- dict cu cod specific -> 1 element cu cele 3 niveluri din dict
|
||||
- dict fara cod (forma veche: unmapped / auto_send) -> 1 element cu problema din context
|
||||
- lista cu {field, message} fara cod -> degradare: problema=message, cauza/fix=""
|
||||
- string plain -> 1 element cu problema=text, cauza/fix=""
|
||||
- JSON corupt -> 1 element cu problema=text brut, cauza/fix=""
|
||||
"""
|
||||
if not rar_error:
|
||||
return []
|
||||
|
||||
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
|
||||
|
||||
# Incercare parsare JSON
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
# String plain sau JSON corupt: degradare gratuoasa
|
||||
return [{"problema": raw[:200], "cauza": "", "fix": "", "field": None}]
|
||||
|
||||
# --- Forma: array de erori ---
|
||||
if isinstance(data, list):
|
||||
rezultat = []
|
||||
for e in data:
|
||||
if not isinstance(e, dict):
|
||||
rezultat.append({"problema": str(e)[:200], "cauza": "", "fix": "", "field": None})
|
||||
continue
|
||||
# Eroare imbogatita (are cod sau problema)
|
||||
if e.get("cod") or e.get("problema"):
|
||||
rezultat.append({
|
||||
"problema": e.get("problema") or e.get("cod") or "",
|
||||
"cauza": e.get("cauza") or e.get("message") or "",
|
||||
"fix": e.get("fix") or "",
|
||||
"field": e.get("field"),
|
||||
})
|
||||
else:
|
||||
# Forma veche: {field, message} fara cod
|
||||
msg = str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
|
||||
elem = {
|
||||
"problema": msg[:200],
|
||||
"cauza": "",
|
||||
"fix": "",
|
||||
"field": e.get("field"),
|
||||
}
|
||||
# Filtreaza elementele complet goale (problema/cauza/fix toate vide)
|
||||
if not (
|
||||
elem["problema"].strip() == ""
|
||||
and elem["cauza"].strip() == ""
|
||||
and elem["fix"].strip() == ""
|
||||
):
|
||||
rezultat.append(elem)
|
||||
return rezultat
|
||||
|
||||
# --- Forma: dict ---
|
||||
if isinstance(data, dict):
|
||||
# Dict imbogatit cu cod explicit
|
||||
if data.get("cod") or data.get("problema"):
|
||||
return [{
|
||||
"problema": data.get("problema") or data.get("cod") or "",
|
||||
"cauza": data.get("cauza") or "",
|
||||
"fix": data.get("fix") or "",
|
||||
"field": data.get("field"),
|
||||
}]
|
||||
# Dict vechi: unmapped
|
||||
if "unmapped" in data:
|
||||
ops = data.get("unmapped") or []
|
||||
coduri = ", ".join(
|
||||
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
|
||||
).strip(", ")
|
||||
problema = f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa"
|
||||
return [{"problema": problema, "cauza": "", "fix": "", "field": None}]
|
||||
# Dict vechi: auto_send
|
||||
if "auto_send" in data:
|
||||
return [{"problema": "Necesita confirmare manuala (auto-send oprit pentru cod)",
|
||||
"cauza": "", "fix": "", "field": None}]
|
||||
# Dict generic necunoscut
|
||||
parti = "; ".join(f"{k}: {v}" for k, v in data.items())
|
||||
if not parti.strip():
|
||||
return []
|
||||
return [{"problema": parti[:200], "cauza": "", "fix": "", "field": None}]
|
||||
|
||||
# Scalar (nr, bool, etc.)
|
||||
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constante auxiliare (microcopy fix, fara logica)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,6 +23,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from .. import errors as _errors
|
||||
from ..auth import rotate_api_key
|
||||
from ..payload_view import prezentare_din_payload
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
@@ -33,6 +34,7 @@ from .labels import (
|
||||
eticheta_worker,
|
||||
format_data_rar,
|
||||
motiv_uman,
|
||||
parse_erori,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
@@ -71,6 +73,8 @@ _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
||||
|
||||
router = APIRouter(tags=["web"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
# Expune parse_erori in toate template-urile (US-006, PRD 5.4)
|
||||
templates.env.globals["parse_erori"] = parse_erori
|
||||
|
||||
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
@@ -327,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"badges": badges,
|
||||
"is_authenticated": True,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
@@ -604,6 +609,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
"rar_status_code": row["rar_status_code"],
|
||||
"rar_error": row["rar_error"],
|
||||
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
||||
"erori_3n": parse_erori(row["rar_error"]),
|
||||
"retry_count": row["retry_count"],
|
||||
"created_at": format_data_rar(row["created_at"]),
|
||||
"updated_at": format_data_rar(row["updated_at"]),
|
||||
@@ -1279,13 +1285,25 @@ async def web_upload_import(
|
||||
except MultipleSheets as ms:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
||||
except FileTooLarge as e:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
|
||||
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=str(e), eroare_upload=eroare_upload
|
||||
))
|
||||
except HeaderError as e:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
|
||||
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
|
||||
))
|
||||
except UnicodeDecodeError as e:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
|
||||
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
|
||||
))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
|
||||
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
|
||||
))
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -1370,6 +1388,52 @@ async def web_save_mapare_coloane(
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
# Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.body()
|
||||
try:
|
||||
json.loads(body)
|
||||
# JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form
|
||||
eroare_mapare = _errors.eroare(
|
||||
"COLOANE_FORMAT_JSON",
|
||||
cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.",
|
||||
)
|
||||
conn = get_connection()
|
||||
try:
|
||||
first_row = conn.execute(
|
||||
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
||||
(import_id,),
|
||||
).fetchone()
|
||||
columns: list[str] = []
|
||||
if first_row:
|
||||
try:
|
||||
rd = decrypt_creds(first_row["raw_json"]) or {}
|
||||
columns = list(rd.keys())
|
||||
except Exception:
|
||||
pass
|
||||
fuzzy: dict[str, list[dict]] = {}
|
||||
for col in columns:
|
||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||
if sugg:
|
||||
fuzzy[col] = sugg
|
||||
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
columns=columns,
|
||||
sample_rows=[],
|
||||
fuzzy_suggestions=fuzzy,
|
||||
canonical_fields=_CANONICAL_FIELDS,
|
||||
format_data=None,
|
||||
eroare_mapare=eroare_mapare,
|
||||
error=True,
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# Colectare perechi coloana fisier → camp canonic din form
|
||||
|
||||
@@ -44,15 +44,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Subordonat: ajutor rapid pe un rand discret ===
|
||||
US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos
|
||||
pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #}
|
||||
<div style="margin-top:10px; font-size:13px; color:var(--muted);
|
||||
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
|
||||
<span>Ajutor:</span>
|
||||
<a href="/?tab=mapari">Mapari</a>
|
||||
<a href="/?tab=nomenclator">Coduri RAR</a>
|
||||
</div>
|
||||
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
|
||||
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
|
||||
|
||||
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
|
||||
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if not account_active %}
|
||||
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
|
||||
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card));"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
|
||||
|
||||
36
app/web/templates/_eroare.html
Normal file
36
app/web/templates/_eroare.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
_eroare.html — macro card_erori(erori) (US-006, PRD 5.4).
|
||||
|
||||
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
|
||||
Afiseaza 3 niveluri intr-un bloc scannabil:
|
||||
- "Problema" (bold, --err)
|
||||
- "De ce" (doar daca ne-gol, --muted)
|
||||
- "Cum repari" (accentuat, --accent)
|
||||
|
||||
Nu hardcodeaza culori — foloseste variabilele CSS din paleta (base.html).
|
||||
Suporta light + dark din box (variabilele se schimba prin [data-theme]).
|
||||
#}
|
||||
|
||||
{% macro card_erori(erori) %}
|
||||
{% if erori %}
|
||||
<div class="eroare-3n">
|
||||
{% for e in erori %}
|
||||
<div class="eroare-3n-item{% if not loop.first %} eroare-3n-sep{% endif %}">
|
||||
<div class="eroare-3n-problema">
|
||||
{% if e.field %}<span class="eroare-3n-camp">{{ e.field }}</span> {% endif %}{{ e.problema }}
|
||||
</div>
|
||||
{% if e.cauza %}
|
||||
<div class="eroare-3n-cauza">
|
||||
<span class="eroare-3n-label">De ce:</span> {{ e.cauza }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if e.fix %}
|
||||
<div class="eroare-3n-fix">
|
||||
<span class="eroare-3n-label">Cum repari:</span> {{ e.fix }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -1,26 +1,28 @@
|
||||
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
||||
|
||||
{# US-007: comutator pe COADA in loc de bifa "auto-send".
|
||||
Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele
|
||||
poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu
|
||||
semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca
|
||||
bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get())
|
||||
la /_import/.../mapare-operatie). Zero atingere backend.
|
||||
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
|
||||
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
|
||||
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
|
||||
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
|
||||
|
||||
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
|
||||
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
|
||||
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
|
||||
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
|
||||
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
|
||||
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
|
||||
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
|
||||
- checked: starea initiala (H4 - reflecta valoarea STOCATA per mapare). #}
|
||||
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
|
||||
{% macro autosend_toggle(form_id='', checked=True) -%}
|
||||
<div class="autosend-toggle" style="display:flex; flex-direction:column; gap:4px;">
|
||||
<span class="muted" style="font-size:12px;">La fisierele viitoare cu aceasta operatie:</span>
|
||||
<label class="chk" style="display:inline-flex; align-items:center; gap:8px; min-height:44px;">
|
||||
<input type="checkbox" name="auto_send" value="true"
|
||||
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
||||
{%- if checked %} checked{% endif %}
|
||||
aria-label="Pune automat in coada la fisierele viitoare cu aceasta operatie">
|
||||
<span><strong>Pune automat in coada</strong></span>
|
||||
</label>
|
||||
<span class="muted" style="font-size:11px;">
|
||||
Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie;
|
||||
nimic nu pleaca la RAR pana confirmi.
|
||||
</span>
|
||||
</div>
|
||||
<label class="autosend-toggle"
|
||||
title="Auto = pune automat in coada la fisierele viitoare cu aceasta operatie. Manual = tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
||||
style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; min-height:36px; cursor:pointer; font-size:13px;">
|
||||
<span class="muted">Manual</span>
|
||||
<input type="checkbox" name="auto_send" value="true"
|
||||
{%- if form_id %} form="{{ form_id }}"{% endif %}
|
||||
{%- if checked %} checked{% endif %}
|
||||
aria-label="In coada: Auto (bifat) sau Manual (nebifat), pentru aceasta operatie"
|
||||
style="width:32px; height:18px; cursor:pointer; accent-color:var(--accent);">
|
||||
<span><strong>Auto</strong></span>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -9,7 +9,22 @@
|
||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
|
||||
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
|
||||
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
|
||||
o singura data, ascunsa implicit. #}
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
||||
<details class="ajutor-mapari" style="margin:0 0 12px;">
|
||||
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
|
||||
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
|
||||
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
|
||||
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
|
||||
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
|
||||
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
|
||||
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if not pending %}
|
||||
<div class="empty">
|
||||
@@ -17,18 +32,13 @@
|
||||
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
|
||||
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Operatie</th>
|
||||
<th>Sugestii</th>
|
||||
<th>Cod RAR</th>
|
||||
<th>Punere in coada</th>
|
||||
<th>In coada</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@@ -88,17 +98,14 @@
|
||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau punerea in coada si salveaza;
|
||||
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</p>
|
||||
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Operatie</th>
|
||||
<th>Cod RAR</th>
|
||||
<th>Punere in coada</th>
|
||||
<th>In coada</th>
|
||||
<th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@@ -160,7 +167,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<div id="import-section">
|
||||
{% set pas = 2 %}{% include '_stepper.html' %}
|
||||
{% from '_eroare.html' import card_erori %}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||
Mapare coloane —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
|
||||
{% if eroare_mapare %}
|
||||
<div style="margin-bottom:12px;">
|
||||
{{ card_erori([eroare_mapare]) }}
|
||||
</div>
|
||||
{% elif message %}
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
|
||||
{% if error %}role="alert"{% endif %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
|
||||
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
|
||||
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
|
||||
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
|
||||
{% if rows %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Cod</th><th>Denumire</th><th>Actualizat</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th>Cod</th>
|
||||
<th>Denumire</th>
|
||||
<th>Actualizat</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><span class="pill">{{ r.cod_prestatie }}</span></td>
|
||||
<td>{{ r.nume_prestatie }}</td>
|
||||
<td style="white-space:normal;">{{ r.nume_prestatie }}</td>
|
||||
<td class="muted">{{ r.updated_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
|
||||
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
|
||||
{% if error %}role="alert"{% endif %}>
|
||||
{{ message }}
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
|
||||
{% if unmapped_ops %}
|
||||
<div class="card" style="border-color:var(--err); background:#241a1a; margin-bottom:14px;">
|
||||
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
||||
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
|
||||
{% if editing %}
|
||||
{%- set err_map = {} -%}
|
||||
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- endif -%}{%- endfor -%}
|
||||
{%- set fix_map = {} -%}
|
||||
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%}
|
||||
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1">
|
||||
<td colspan="10" style="background:rgba(91,141,239,.06);">
|
||||
<form class="rand-editare"
|
||||
@@ -31,12 +32,12 @@
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:10px;"
|
||||
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:10px;"
|
||||
role="alert">{{ message }}</div>
|
||||
{% endif %}
|
||||
<div class="rand-eroare-banner" role="alert"
|
||||
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
|
||||
background:#241a1a; border-radius:6px; font-size:13px;">
|
||||
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
|
||||
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
|
||||
</div>
|
||||
|
||||
@@ -50,6 +51,9 @@
|
||||
{% if err_map.get(nume) %}
|
||||
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
|
||||
{% endif %}
|
||||
{% if fix_map.get(nume) %}
|
||||
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -83,13 +87,23 @@
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
{%- set disp_fix_map = {} -%}
|
||||
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%}
|
||||
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
|
||||
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
|
||||
<td class="muted">{{ row.row_index + 1 }}</td>
|
||||
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}</td>
|
||||
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
|
||||
<td>{{ res.get('data_prestatie') or '' }}</td>
|
||||
<td>{{ res.get('odometru_final') or '' }}</td>
|
||||
<td>{{ res.get('vin') or '<span class="muted">—</span>' | safe }}
|
||||
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ res.get('nr_inmatriculare') or '' }}
|
||||
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ res.get('data_prestatie') or '' }}
|
||||
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ res.get('odometru_final') or '' }}
|
||||
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ op or '<span class="muted">—</span>' | safe }}</td>
|
||||
<td><span class="pill s-{{ status }}">{{ status }}</span></td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
|
||||
{% if not account_active %}
|
||||
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
|
||||
background:#201c0f; border-radius:6px; font-size:13px;">
|
||||
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
|
||||
porneste automat dupa activare de catre administrator.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% from "_eroare.html" import card_erori %}
|
||||
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||
@@ -27,7 +28,11 @@
|
||||
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if motiv %}
|
||||
{% if erori_3n %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
{{ card_erori(erori_3n) }}
|
||||
</div>
|
||||
{% elif motiv %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div class="muted" style="font-size:12px;">Motiv</div>
|
||||
<div>{{ motiv }}</div>
|
||||
@@ -49,7 +54,7 @@
|
||||
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
|
||||
|
||||
{% if corectie_msg %}
|
||||
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
|
||||
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:10px;"
|
||||
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
{% set pas = 1 %}{% include '_stepper.html' %}
|
||||
{# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul
|
||||
de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #}
|
||||
{% from '_eroare.html' import card_erori %}
|
||||
<div class="card" style="border-color:var(--accent);">
|
||||
|
||||
{% if message %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:12px;"
|
||||
{% if eroare_upload %}
|
||||
<div style="margin-bottom:12px;">
|
||||
{{ card_erori([eroare_upload]) }}
|
||||
</div>
|
||||
{% elif error %}
|
||||
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
|
||||
role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sheets %}
|
||||
<div class="flash" style="border-color:var(--warn); background:#201c0f; margin-bottom:12px;">
|
||||
<div class="flash" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:12px;">
|
||||
Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
||||
{% set VERBS = {
|
||||
'activate': ('Activeaza', '/admin/activate', ''),
|
||||
'block': ('Blocheaza', '/admin/block', ''),
|
||||
'archive': ('Arhiveaza', '/admin/archive', ''),
|
||||
'delete': ('Sterge', '/admin/delete', 'danger')
|
||||
} %}
|
||||
|
||||
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
|
||||
{% if rows %}
|
||||
{# Bara bulk: form propriu (id=bulk-<block>); checkbox-urile randurilor se leaga prin atributul
|
||||
HTML5 form= (fara form-uri imbricate). Ascunsa pana exista o selectie (JS). #}
|
||||
<form id="bulk-{{ block_id }}" method="post" class="bulk-form" data-block="{{ block_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="bulk-bar" hidden>
|
||||
<span class="bulk-count muted" style="font-size:13px;">0 selectate</span>
|
||||
{% for v in bulk_verbs %}
|
||||
{% set label, action, cls = VERBS[v] %}
|
||||
<button type="submit" formaction="{{ action }}"
|
||||
{% if v == 'delete' %}onclick="return confirm('Stergi conturile selectate? (stergere soft, datele se purjeaza)');"{% endif %}
|
||||
style="{% if cls == 'danger' %}background:var(--card); color:var(--err); border-color:var(--err);{% endif %}">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
|
||||
aria-label="Selecteaza tot"></th>
|
||||
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for acct in rows %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="account_id" value="{{ acct.id }}" form="bulk-{{ block_id }}"
|
||||
class="row-check" data-block="{{ block_id }}"
|
||||
aria-label="Selecteaza contul {{ acct.name }}"></td>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td><span class="pill">{{ acct.status }}</span></td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
<details class="kebab">
|
||||
<summary class="cardlink" style="list-style:none; cursor:pointer; display:inline-flex;
|
||||
padding:4px 10px;" aria-label="Actiuni pentru {{ acct.name }}">⋯</summary>
|
||||
<div class="kebab-menu">
|
||||
{% for v in row_verbs %}
|
||||
{% set label, action, cls = VERBS[v] %}
|
||||
{# Confirm fara nume interpolat: un apostrof in numele firmei (free-form) ar rupe
|
||||
string-ul JS din atributul inline (entitatea ' e decodata inainte de parse). #}
|
||||
<form method="post" action="{{ action }}"
|
||||
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit" {% if cls == 'danger' %}style="color:var(--err);"{% endif %}>{{ label }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<style>
|
||||
/* Bara de actiuni bulk — ascunsa pana exista selectie. `[hidden]` trebuie sa invinga
|
||||
display-ul, deci stilul sta in CSS (NU inline cu display:flex, care ar invinge [hidden]). */
|
||||
.bulk-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px;
|
||||
padding:8px 10px; border:1px solid var(--line); border-radius:8px;
|
||||
background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
|
||||
.bulk-bar[hidden] { display:none; }
|
||||
/* Kebab per-rand (reuseaza estetica meniului de cont) */
|
||||
.kebab { position:relative; display:inline-block; }
|
||||
.kebab > summary::-webkit-details-marker { display:none; }
|
||||
.kebab-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:140px; z-index:40;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
|
||||
.kebab[open] > summary { background:var(--line); }
|
||||
.kebab-menu form { margin:0; }
|
||||
.kebab-menu button { display:block; width:100%; text-align:left; background:transparent; border:none;
|
||||
color:var(--ink); font:inherit; padding:7px 10px; border-radius:6px; cursor:pointer;
|
||||
min-height:36px; }
|
||||
.kebab-menu button:hover { background:var(--line); }
|
||||
</style>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
|
||||
<h2 style="margin:0;">Panou admin</h2>
|
||||
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
|
||||
@@ -10,96 +108,45 @@
|
||||
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Conturi in asteptare -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
|
||||
{% if pending %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in pending %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/activate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit">Activeaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont in asteptare.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ lifecycle_block("Conturi in asteptare", pending, "pending",
|
||||
['activate', 'block', 'archive', 'delete'],
|
||||
['activate', 'block', 'archive', 'delete']) }}
|
||||
|
||||
<!-- Conturi active -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
|
||||
{% if active %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in active %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/deactivate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ lifecycle_block("Conturi active", active, "active",
|
||||
['block', 'archive', 'delete'],
|
||||
['block', 'archive', 'delete']) }}
|
||||
|
||||
<!-- Contul dev default (id=1) -->
|
||||
{% if default_account %}
|
||||
<div class="card" style="border-color:var(--muted);">
|
||||
<p class="muted" style="margin:0;font-size:13px;">
|
||||
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
|
||||
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
|
||||
{{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
|
||||
['activate', 'delete'],
|
||||
['activate', 'delete']) }}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
|
||||
document.querySelectorAll('.master-check').forEach(function(master) {
|
||||
var block = master.getAttribute('data-block');
|
||||
var rows = Array.prototype.slice.call(
|
||||
document.querySelectorAll('.row-check[data-block="' + block + '"]'));
|
||||
var form = document.getElementById('bulk-' + block);
|
||||
var bar = form ? form.querySelector('.bulk-bar') : null;
|
||||
var count = form ? form.querySelector('.bulk-count') : null;
|
||||
|
||||
function refresh() {
|
||||
var n = rows.filter(function(r) { return r.checked; }).length;
|
||||
if (bar) bar.hidden = (n === 0);
|
||||
if (count) count.textContent = n + ' selectate';
|
||||
master.checked = (n > 0 && n === rows.length);
|
||||
master.indeterminate = (n > 0 && n < rows.length);
|
||||
}
|
||||
master.addEventListener('change', function() {
|
||||
rows.forEach(function(r) { r.checked = master.checked; });
|
||||
refresh();
|
||||
});
|
||||
rows.forEach(function(r) { r.addEventListener('change', refresh); });
|
||||
refresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,9 +13,26 @@
|
||||
// useTemplateFragments parseaza tot intr-un <template>, pastrand rand + OOB impreuna.
|
||||
htmx.config.useTemplateFragments = true;
|
||||
</script>
|
||||
<script>
|
||||
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
|
||||
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
|
||||
(function() {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (!t) {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch(e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
|
||||
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
||||
@@ -24,7 +41,7 @@
|
||||
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
|
||||
main { padding:24px; max-width:1100px; margin:0 auto; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin-bottom:16px; }
|
||||
.banner { border-left:3px solid var(--err); background:#241a1a; }
|
||||
.banner { border-left:3px solid var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); }
|
||||
.banner.hidden { display:none; }
|
||||
/* Tabelele de date au multe coloane; pe ecrane inguste scroll IN card, nu
|
||||
impinge layout-ul paginii (altfel toata pagina scrolleaza orizontal). */
|
||||
@@ -46,7 +63,7 @@
|
||||
text-align:center; transition:border-color .15s,background .15s; }
|
||||
.drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); }
|
||||
/* Banner varianta warn (nu eroare) */
|
||||
.banner.warn { border-left-color:var(--warn); background:#201c0f; }
|
||||
.banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); }
|
||||
/* Bara confirmare sticky */
|
||||
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
|
||||
padding:12px 16px; display:flex; align-items:flex-start; gap:16px;
|
||||
@@ -59,7 +76,7 @@
|
||||
.cardlink { font-size:13px; padding:7px 10px; border-radius:6px; display:inline-flex;
|
||||
align-items:center; min-height:36px; white-space:nowrap; }
|
||||
.cardlink:hover { background:var(--line); }
|
||||
.flash { background:#16241c; border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
|
||||
.flash { background:color-mix(in srgb, var(--ok) 12%, var(--card)); border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
|
||||
margin:0 0 12px; font-size:13px; }
|
||||
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
|
||||
flex-wrap:wrap; }
|
||||
@@ -86,14 +103,134 @@
|
||||
border-color:var(--line); border-bottom-color:var(--card); }
|
||||
.tab-panel { min-height:120px; }
|
||||
.status-bar { margin-bottom:12px; }
|
||||
/* Eroare 3 niveluri (US-006, PRD 5.4) */
|
||||
.eroare-3n { margin-top:10px; }
|
||||
.eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err);
|
||||
background:color-mix(in srgb, var(--err) 8%, var(--card));
|
||||
border-radius:0 6px 6px 0; }
|
||||
.eroare-3n-sep { margin-top:6px; }
|
||||
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
|
||||
.eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; }
|
||||
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-label { font-weight:500; }
|
||||
/* Inline fix per camp in preview */
|
||||
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
|
||||
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
|
||||
.cont-menu-wrap { position:relative; }
|
||||
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
|
||||
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
|
||||
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.icon-btn:hover { background:var(--line); }
|
||||
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
|
||||
.cont-menu[hidden] { display:none; }
|
||||
.cont-menu a, .cont-menu button { display:block; width:100%; text-align:left; background:transparent;
|
||||
border:none; color:var(--ink); text-decoration:none; font:inherit; padding:8px 10px;
|
||||
border-radius:6px; cursor:pointer; min-height:36px; }
|
||||
.cont-menu a:hover, .cont-menu button:hover { background:var(--line); }
|
||||
.cont-menu hr { border:none; border-top:1px solid var(--line); margin:4px 0; }
|
||||
.cont-menu form { margin:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
<span class="muted" style="margin-left:auto; font-size:13px;">v{{ version }}</span>
|
||||
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
|
||||
<button id="tema-toggle" class="icon-btn"
|
||||
aria-label="Comuta tema (luminos/intunecat)"
|
||||
title="Comuta tema">☀</button>
|
||||
<span class="muted" style="font-size:13px;">v{{ version }}</span>
|
||||
{% if is_authenticated|default(false) %}
|
||||
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
|
||||
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
|
||||
<div class="cont-menu-wrap">
|
||||
<button id="cont-menu-toggle" class="icon-btn"
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Panou admin</a>{% endif %}
|
||||
<hr>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
|
||||
<button role="menuitem" type="submit">Iesi din cont</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
<script>
|
||||
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
|
||||
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
|
||||
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
|
||||
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
|
||||
(function() {
|
||||
var btn = document.getElementById('tema-toggle');
|
||||
if (!btn) return;
|
||||
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
|
||||
function _syncIcon(t) {
|
||||
if (t === 'light') {
|
||||
btn.innerHTML = '☾';
|
||||
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
|
||||
btn.title = 'Comuta tema (intunecat)';
|
||||
} else {
|
||||
btn.innerHTML = '☀';
|
||||
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
|
||||
btn.title = 'Comuta tema (luminos)';
|
||||
}
|
||||
}
|
||||
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
|
||||
function _setTheme(t) {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
try { localStorage.setItem('theme', t); } catch(e) {}
|
||||
_syncIcon(t);
|
||||
}
|
||||
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
|
||||
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||
btn.addEventListener('click', function() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
_setTheme(cur === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
|
||||
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
|
||||
(function() {
|
||||
var toggle = document.getElementById('cont-menu-toggle');
|
||||
var menu = document.getElementById('cont-menu');
|
||||
if (!toggle || !menu) return;
|
||||
function open() {
|
||||
menu.hidden = false;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
}
|
||||
function close(refocus) {
|
||||
menu.hidden = true;
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('click', onDocClick, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
if (refocus) toggle.focus();
|
||||
}
|
||||
function onDocClick(e) {
|
||||
if (!menu.contains(e.target) && e.target !== toggle) close(false);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(true); }
|
||||
}
|
||||
toggle.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open(); else close(false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Nav cont: link admin (doar pentru admini) + logout -->
|
||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
|
||||
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
|
||||
<form method="post" action="/logout" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
|
||||
</form>
|
||||
</div>
|
||||
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
|
||||
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
|
||||
|
||||
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
@@ -20,14 +14,12 @@
|
||||
|
||||
<!-- Tab-bar: navigare intre sectiuni -->
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc
|
||||
ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #}
|
||||
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
|
||||
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
|
||||
{% set tabs = [
|
||||
("acasa", "Acasa", "tab-acasa"),
|
||||
("mapari", "Mapari", "tab-mapari"),
|
||||
("cont", "Cont", "tab-cont"),
|
||||
("nomenclator", "Nomenclator", "tab-nomenclator"),
|
||||
("integrare", "Integrare", "tab-integrare")
|
||||
("mapari", "Mapari", "tab-mapari")
|
||||
] %}
|
||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
|
||||
|
||||
@@ -34,6 +34,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import errors
|
||||
from ..config import Settings, get_settings, load_test_credentials
|
||||
from ..crypto import decrypt_creds
|
||||
from ..db import get_connection, init_db, write_heartbeat
|
||||
@@ -144,7 +145,9 @@ def claim_one(conn) -> dict | None:
|
||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||
"WHERE s.status='queued' "
|
||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||
"AND COALESCE(a.active, 1) = 1 "
|
||||
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
|
||||
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
|
||||
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
|
||||
"ORDER BY s.id LIMIT 1",
|
||||
(_iso(_now()),),
|
||||
).fetchone()
|
||||
@@ -200,7 +203,14 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
|
||||
return "sent"
|
||||
except RarError as exc:
|
||||
if exc.status_code == 400:
|
||||
detail = json.dumps(exc.field_errors, ensure_ascii=False) if exc.field_errors else str(exc)
|
||||
if exc.field_errors:
|
||||
enriched = [
|
||||
errors.eroare("RAR_VALIDARE", field=fe.get("field"), cauza=fe.get("message"))
|
||||
for fe in exc.field_errors
|
||||
]
|
||||
else:
|
||||
enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))]
|
||||
detail = json.dumps(enriched, ensure_ascii=False)
|
||||
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail)
|
||||
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
|
||||
return "needs_data"
|
||||
@@ -406,7 +416,8 @@ def run() -> int:
|
||||
token = sessions.get_token(conn, account_id, creds)
|
||||
except RarAuthError as exc:
|
||||
# Creds gresite (login 401): NU se face retry (plan, failure registry).
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error="credentiale RAR invalide")
|
||||
mark(conn, sid, "error", rar_status_code=401,
|
||||
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
|
||||
print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True)
|
||||
continue
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -175,6 +175,127 @@ Aplicate deja pe ambele medii (test + producție):
|
||||
→ Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue
|
||||
(stare `needs_data`) ca să nu primești 4xx de la RAR.
|
||||
|
||||
## Envelope de eroare imbogatit (PRD 5.4)
|
||||
|
||||
### Forma unui obiect de eroare
|
||||
|
||||
Incepand cu PRD 5.4, fiecare obiect de eroare returnat de gateway contine **6 chei**:
|
||||
|
||||
| Cheie | Tip | Rol | Back-compat |
|
||||
|---|---|---|---|
|
||||
| `field` | string \| null | Campul care a generat eroarea (null daca eroarea e globala) | DA — existent anterior |
|
||||
| `message` | string | Mesajul scurt (identic cu `cauza` cand e disponibila, altfel `problema`) | DA — existent anterior |
|
||||
| `cod` | string | Identificator stabil de tip eroare (ex. `VIN_FORMAT`). Camp nou. | NU — adaugat 5.4 |
|
||||
| `problema` | string | Ce s-a intamplat — descriere scurta, inteligibila pentru utilizator | NU — adaugat 5.4 |
|
||||
| `cauza` | string | De ce a aparut eroarea — concret; pentru erorile RAR 400, mesajul exact de la RAR (passthrough) | NU — adaugat 5.4 |
|
||||
| `fix` | string | Ce trebuie facut pentru remediere | NU — adaugat 5.4 |
|
||||
|
||||
**Exemplu JSON concret** (eroare VIN invalid, returnat de `POST /v1/prezentari/valideaza`):
|
||||
|
||||
```json
|
||||
{
|
||||
"field": "vin",
|
||||
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||
"cod": "VIN_FORMAT",
|
||||
"problema": "VIN invalid",
|
||||
"cauza": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
|
||||
"fix": "Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q."
|
||||
}
|
||||
```
|
||||
|
||||
### Nota de back-compat
|
||||
|
||||
Cheile `field` si `message` sunt **pastrate neschimbate** pe toate raspunsurile. Cheile `cod`, `problema`, `cauza`, `fix` sunt **aditive** (camp nou in plus). Clientii care citesc doar `field`/`message` (sau `error`/`message` la import) continua sa functioneze fara modificare.
|
||||
|
||||
### Unde apare envelope-ul imbogatit
|
||||
|
||||
**1. `POST /v1/prezentari/valideaza` (dry-run)**
|
||||
|
||||
Campul `erori` (array) si campul `nemapate` (array) din raspuns contin obiecte cu toate cele 6 chei.
|
||||
|
||||
**2. `submissions.rar_error` (stocat in DB, vizibil prin `GET /v1/prezentari/{id}` si in dashboard)**
|
||||
|
||||
Campul `rar_error` e superset al formei de mai sus si variaza cu starea submission-ului:
|
||||
|
||||
- `needs_data` — array de obiecte `{field, message, cod, problema, cauza, fix}`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"field": "dataPrestatie",
|
||||
"message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
|
||||
"cod": "RAR_VALIDARE",
|
||||
"problema": "RAR a respins prezentarea",
|
||||
"cauza": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
|
||||
"fix": "Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR."
|
||||
}
|
||||
]
|
||||
```
|
||||
- `needs_mapping` (cod nemapat): obiect cu cheile `unmapped` (array), `cod`, `problema`, `cauza`, `fix`:
|
||||
```json
|
||||
{
|
||||
"unmapped": ["SCHIMB_ULEI_COMPLET"],
|
||||
"cod": "COD_NEMAPAT",
|
||||
"problema": "Lipseste codul RAR al operatiei",
|
||||
"cauza": "Operatia SCHIMB_ULEI_COMPLET nu are un cod RAR mapat.",
|
||||
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate)."
|
||||
}
|
||||
```
|
||||
- `needs_mapping` cu `auto_send` oprit: obiect cu `auto_send`, `cod: "AUTO_SEND_OPRIT"`, `problema`, `cauza`, `fix`.
|
||||
- Eroare RAR 400: array imbogatit cu `cod: "RAR_VALIDARE"` pe fiecare element.
|
||||
- Eroare RAR 401 (creds invalide): obiect cu `cod: "RAR_CREDS_INVALIDE"`, `problema`, `cauza`, `fix`.
|
||||
|
||||
**3. Erori de import (`POST /v1/import`, preview, commit)**
|
||||
|
||||
Campul `detail` din raspunsurile de eroare este superset: contine cheile vechi `error`/`message` plus `cod`, `problema`, `cauza`, `fix`.
|
||||
|
||||
**Exceptii din scope 5.4**: erorile de login/signup si CSRF raman mesaje plate (fara envelope imbogatit).
|
||||
|
||||
### Tabel cod → problema / fix (toate codurile din `app/errors.CATALOG`)
|
||||
|
||||
#### Validare date prestatie
|
||||
|
||||
| Cod | Problema | Fix |
|
||||
|---|---|---|
|
||||
| `VIN_FORMAT` | VIN invalid | Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q. |
|
||||
| `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii sau cratima (ex. B123ABC). |
|
||||
| `DATA_FORMAT` | Data prestatiei in format gresit | Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22). |
|
||||
| `DATA_PREA_VECHE` | Data prestatiei prea veche | RAR accepta prestatii doar incepand cu 01.12.2024; verifica data prestatiei. |
|
||||
| `DATA_VIITOR` | Data prestatiei in viitor | Data prestatiei nu poate fi dupa ziua de azi; corecteaza data. |
|
||||
| `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | Scrie kilometrajul final ca numar intreg, fara zecimale sau text (ex. 145000). |
|
||||
| `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l. |
|
||||
| `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | Scrie kilometrajul initial ca numar intreg, fara zecimale sau text. |
|
||||
| `ODOMETRU_INITIAL_ORDINE` | Odometru initial mai mare decat finalul | Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final; verifica cele doua valori. |
|
||||
| `PRESTATII_GOALE` | Nicio prestatie | Adauga cel putin o prestatie cu cod RAR valid. |
|
||||
| `B64_INVALID` | Imaginea nu este base64 valid | Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine. |
|
||||
|
||||
#### Mapare operatie
|
||||
|
||||
| Cod | Problema | Fix |
|
||||
|---|---|---|
|
||||
| `COD_NEMAPAT` | Lipseste codul RAR al operatiei | Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate). |
|
||||
| `AUTO_SEND_OPRIT` | Necesita confirmare manuala | Codul e mapat cu trimitere automata oprita; verifica randul si pune-l manual in coada. |
|
||||
|
||||
#### Erori RAR (raspuns live de la RAR)
|
||||
|
||||
| Cod | Problema | Fix |
|
||||
|---|---|---|
|
||||
| `RAR_VALIDARE` | RAR a respins prezentarea | Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR. |
|
||||
| `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | Verifica email-ul si parola contului RAR in tab-ul Cont; trimiterea nu se reincearca automat la credentiale gresite. |
|
||||
|
||||
#### Import fisier
|
||||
|
||||
| Cod | Problema | Fix |
|
||||
|---|---|---|
|
||||
| `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand. |
|
||||
| `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | Asigura-te ca primul rand contine numele coloanelor (ex. VIN, Numar, Data). |
|
||||
| `IMPORT_ENCODING` | Codare de caractere nesuportata | Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca. |
|
||||
| `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | Incarca un fisier .xlsx sau .csv valid. |
|
||||
| `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | Pastreaza datele intr-o singura foaie sau alege foaia de import. |
|
||||
| `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | Mapeaza intai coloanele fisierului la campurile cerute, apoi continua. |
|
||||
| `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | Numarul confirmat difera de randurile gata de trimis; verifica preview-ul si reconfirma. |
|
||||
| `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | Editarea salvata este ilizibila (probabil cheia s-a schimbat); reediteaza randul. |
|
||||
| `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect). |
|
||||
|
||||
## Nomenclator prestații (18 coduri, verificat live 2026-06-15)
|
||||
|
||||
| cod | nume |
|
||||
|
||||
277
docs/design/5.5-uniformizare-ui.md
Normal file
277
docs/design/5.5-uniformizare-ui.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Design 5.5 — Uniformizare & standardizare UI/UX
|
||||
|
||||
**Stare**: aprobat (decizii utilizator 2026-06-23, vezi §10)
|
||||
**Context**: dashboard web HTMX (`app/web/templates/`), paleta dark/light deja livrata (5.3),
|
||||
erori 3-niveluri (5.4). Acest document = sursa de adevar **vizuala** pentru PRD 5.5. Unde PRD-ul
|
||||
descrie *ce* livram pe stories, aici descriem *cum arata* si *de ce*.
|
||||
|
||||
> Nu reinventam estetica. Paleta, tipografia si tokenii din `base.html` (5.3) raman **NESCHIMBATI
|
||||
> la octet**. Standardizarea = aducem toate tabelele si paginile la acelasi vocabular de componente
|
||||
> care exista deja in tabelul Trimiteri (`_submissions.html`), tabelul considerat corect de referinta.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema (audit pe codul real)
|
||||
|
||||
Inventar al neuniformitatii curente:
|
||||
|
||||
| Suprafata | Simptom | Referinta corecta |
|
||||
|-----------|---------|-------------------|
|
||||
| Tabel **Mapari** (`_mapari.html`) | Labartat: coloana "Punere in coada" injecteaza prin macro `autosend_toggle` 3 randuri de text explicativ pe **fiecare** linie → randuri inalte, butoanele **Salveaza/Sterge** ies din viewport, trebuie scroll orizontal | grila Trimiteri |
|
||||
| Tabel **Nomenclator** (`_nomenclator.html`) | Functional dar minim; nu imparte exact acelasi aspect/hover/aliniere cu Trimiteri | grila Trimiteri |
|
||||
| **Acasa** (`_acasa.html`) | Sectiune "Ajutor: Mapari / Coduri RAR" redundanta (wayfinding repetat) | — (se elimina) |
|
||||
| **Navigare** | Cont, Integrare, Nomenclator stau ca tab-uri amestecate cu lucrul zilnic; logout + link admin sunt agatate ad-hoc in coltul dreapta-sus al dashboard-ului, absente pe alte pagini | meniu de cont dedicat |
|
||||
| **Panou admin** (`admin.html`) | Conturile in asteptare au doar "Activeaza" per-rand; lipsesc selectie multipla si actiunile blocare/arhivare/stergere. Nota "Cont dev implicit" e jargon intern nederivabil | tabel cu selectie + bara bulk |
|
||||
|
||||
Principiu de standardizare: **un singur tabel, o singura componenta de antet de sectiune, un singur
|
||||
loc pentru ajutor** (link/disclosure, nu text inline repetat pe randuri).
|
||||
|
||||
---
|
||||
|
||||
## 2. Design tokens (existenti — se reutilizeaza, nu se modifica)
|
||||
|
||||
Din `base.html` (`:root` dark + `[data-theme="light"]`). Citat aici doar ca referinta; **nicio
|
||||
valoare noua de culoare**. Orice suprafata noua foloseste `color-mix(... var(--card))` pentru stari
|
||||
(lectia 5.3: zero literali hardcodati, altfel se sparge light mode).
|
||||
|
||||
```
|
||||
--bg --card --ink --muted --line
|
||||
--ok (verde) --warn (chihlimbar) --err (rosu) --accent (albastru)
|
||||
```
|
||||
|
||||
Spacing: cardurile 16-20px padding; celule tabel `8px 10px`; gap-uri 6/8/12/16px (scara existenta).
|
||||
Radius: 6px controale, 10px carduri, 99px pill-uri. Tipografie: tabel 14px `tabular-nums`,
|
||||
antet `th` 12px uppercase `--muted`. **Nu introducem fonturi sau marimi noi.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Componenta canonica: Tabelul standard
|
||||
|
||||
Tabelul Trimiteri defineste contractul. Orice tabel din aplicatie il respecta:
|
||||
|
||||
```
|
||||
.tablewrap > table
|
||||
thead th -> 12px uppercase, color --muted, font-weight 500, white-space nowrap
|
||||
tbody td -> 14px, padding 8px 10px, border-bottom 1px var(--line), nowrap implicit
|
||||
stare -> <span class="pill {s-*}">{text uman}</span> (glifa+text, nu doar culoare)
|
||||
coloana lunga (motiv) -> white-space:normal; max-width:280px (singura exceptie de la nowrap)
|
||||
empty state -> .empty (centrat, --muted, cu CTA contextual)
|
||||
```
|
||||
|
||||
Reguli care fac diferenta vizibila fata de "labartat":
|
||||
1. **Coloanele de control sunt inguste si nowrap.** Niciun text explicativ in celule. Explicatiile
|
||||
traiesc o singura data, in antetul cardului (link "Ajutor") sau intr-un `<details>`.
|
||||
2. **Actiunile incap fara scroll orizontal.** Coloana "Actiuni" la dreapta, `white-space:nowrap`,
|
||||
butoane scurte. Pe ecrane inguste scroll-ul ramane IN card (`.tablewrap`), nu in pagina.
|
||||
3. **Densitate constanta.** Inaltimea randului = o linie de text + padding. Sub-text (ex. "2 blocate",
|
||||
"acum: COD") merge in `<div class="muted" style="font-size:12px">` sub valoarea principala, nu
|
||||
pe coloana separata.
|
||||
|
||||
### 3.1 Antet de sectiune standard (cu Ajutor)
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| De rezolvat [ Ajutor ] | <- h2 15px la stanga, link la dreapta
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
`Ajutor` = link discret `.cardlink` care comuta un `<details>`/panou de text (vezi §5). Mutam acolo
|
||||
toata proza care azi se repeta pe randuri. Un singur loc, citit la nevoie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tabelul Mapari — inainte / dupa
|
||||
|
||||
### Inainte (labartat)
|
||||
Fiecare rand din "De rezolvat" si "Mapari salvate" poarta `autosend_toggle`, care randeaza:
|
||||
- "La fisierele viitoare cu aceasta operatie:" (12px)
|
||||
- checkbox + **"Pune automat in coada"**
|
||||
- "Nebifat = «Tine pentru verificare». Doar pentru aceasta operatie; nimic nu pleaca la RAR..." (11px)
|
||||
|
||||
x N randuri. Coloana e mai lata decat selectul de cod; Salveaza/Sterge sunt impinse afara.
|
||||
|
||||
### Dupa (compact, ca Trimiteri)
|
||||
|
||||
```
|
||||
De rezolvat [ Ajutor ]
|
||||
-----------------------------------------------------------------
|
||||
OPERATIE SUGESTII COD RAR IN COADA ACTIUNI
|
||||
RevTehBP A012 (88%) [ A012 v ] (o) Auto [Salveaza]
|
||||
2 blocate ( ) Manual
|
||||
-----------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana **IN COADA** = comutator scurt cu doua stari etichetate **Auto** / **Manual** (radio sau
|
||||
switch), fara nicio propozitie. Tooltip pe control: "Auto = pune automat in coada la fisierele
|
||||
viitoare cu aceasta operatie; Manual = tine pentru verificare."
|
||||
- Explicatia completa (de ce exista maparile, ce inseamna Auto vs Manual, ce e blocat) → in panoul
|
||||
**Ajutor** din antet, scris o singura data.
|
||||
- **Invariant backend pastrat**: controlul emite tot `name="auto_send" value="true"` cu semantica de
|
||||
prezenta (bifat→true, absent→false), exact ca azi. Zero atingere backend (lectia 5.3/3.6:
|
||||
reskin la nivel de macro, parserele `/mapari` si `/_import/.../mapare-operatie` raman valide).
|
||||
- "Mapari salvate" si "Formate de coloane" → aceeasi grila; sub-textul ("acum: COD — nume",
|
||||
"N coloane", maparea coloana→camp) ramane `muted` 12px sub valoare, nu pe coloane separate verbose.
|
||||
|
||||
Macro-ul `autosend_toggle` se rescrie compact (acelasi `name`/`form`/`checked`), deci se schimba
|
||||
intr-un singur loc si se propaga si in fluxul de import (mapcoloane) unde e refolosit.
|
||||
|
||||
---
|
||||
|
||||
## 5. Panoul Ajutor (mapari)
|
||||
|
||||
Un `<details>` nativ in antetul cardului "De rezolvat" (sau link care expandeaza acelasi `<details>`),
|
||||
inchis implicit, fara JS:
|
||||
|
||||
```
|
||||
Ajutor (v)
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
- Operatii necunoscute raman blocate in needs_mapping si NU pleaca la RAR pana le mapezi.
|
||||
- Sugestiile (%) vin din potrivire fuzzy pe denumire — verifica-le inainte sa salvezi.
|
||||
- In coada: Auto = la urmatoarele fisiere cu aceasta operatie, randurile intra automat in coada.
|
||||
Manual = raman pentru verificare; nimic nu pleaca la RAR pana confirmi tu.
|
||||
- La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
```
|
||||
|
||||
Avantaj: text scris o data, accesibil, fara cost de inaltime pe fiecare rand. `<details>` =
|
||||
accesibil din tastatura nativ, fara dependente.
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigare: meniu hamburger (decizie: dropdown ancorat dreapta-sus)
|
||||
|
||||
### 6.1 Header
|
||||
|
||||
```
|
||||
[Gateway RAR AUTOPASS] [test] [☀] v1.0 [☰]
|
||||
|
|
||||
+------------------+
|
||||
| Cont |
|
||||
| Integrare |
|
||||
| Nomenclator |
|
||||
| Panou admin | <- doar admin
|
||||
|------------------|
|
||||
| Iesi din cont | <- form POST /logout
|
||||
+------------------+
|
||||
```
|
||||
|
||||
- Iconita `☰` (`min 36x36`, `aria-label="Meniu cont"`, `aria-expanded`, `aria-controls`) langa toggle-ul
|
||||
de tema. Dropdown ancorat sub iconita, aliniat la dreapta. **Fara overlay** pe pagina.
|
||||
- Inchidere: click in afara, `Esc`, sau selectarea unui element. Focus trap minimal: `Esc` readuce
|
||||
focusul pe `☰`. Navigare cu sageti optionala (consistent cu pattern-ul tab existent), dar `Tab`
|
||||
natural e suficient.
|
||||
- Continut **dependent de autentificare** (vezi §6.3).
|
||||
|
||||
### 6.2 Tab-bar dupa mutare (decizie: doar Acasa · Mapari)
|
||||
|
||||
```
|
||||
[ Acasa ] [ Mapari ]
|
||||
```
|
||||
|
||||
Cont, Integrare, Nomenclator parasesc tab-bar-ul → meniul `☰`. Raman doar cele doua suprafete de
|
||||
**lucru zilnic**. Badge-urile de contoare (Mapari) raman pe tab. Deep-link `?tab=` si rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` raman valide (accesate acum din meniu, nu din tab-bar) —
|
||||
deci zero rute moarte, doar punctul de intrare se muta.
|
||||
|
||||
### 6.3 Stare de autentificare in header
|
||||
|
||||
`base.html` e partajat de `login.html`, `signup.html`, `dashboard.html`, `admin.html`. Meniul trebuie
|
||||
sa stie daca esti logat:
|
||||
|
||||
- **Autentificat**: arata Cont, Integrare, Nomenclator, (Panou admin daca `is_admin`), separator,
|
||||
"Iesi din cont" (form `POST /logout` cu `csrf_token`).
|
||||
- **Neautentificat** (login/signup): meniul arata doar "Autentificare" / "Inregistrare" (sau iconita
|
||||
`☰` ascunsa pe aceste pagini — vezi PRD US). Niciun link de cont, niciun logout.
|
||||
|
||||
Necesita ca `base.html` sa primeasca `is_authenticated`, `is_admin`, `csrf_token` in context. Se
|
||||
adauga ca un helper de context partajat (un singur loc), nu duplicat in fiecare render. Acesta e
|
||||
singurul "backend touch" din zona de navigare si trebuie sa fie aditiv si defensiv (lipsa cheilor →
|
||||
meniu in stare neautentificata, nu eroare).
|
||||
|
||||
---
|
||||
|
||||
## 7. Panou admin: selectie + actiuni bulk
|
||||
|
||||
### 7.1 Tabel conturi in asteptare (si analog conturi active)
|
||||
|
||||
```
|
||||
[v] Selecteaza tot 2 selectate:
|
||||
[Activeaza] [Blocheaza] [Arhiveaza] [Sterge] <- bara bulk, apare la selectie
|
||||
-----------------------------------------------------------------------
|
||||
[v] ID COMPANIE CUI EMAIL INREGISTRAT ACTIUNI
|
||||
[v] 7 Auto SRL RO123 a@b.ro 12.06.2026 [ ... ]
|
||||
[ ] 8 Moto SA RO456 c@d.ro 13.06.2026 [ ... ]
|
||||
-----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Coloana de **checkbox** la stanga + un master "selecteaza tot" in antet.
|
||||
- **Bara de actiuni bulk** ascunsa pana exista o selectie; afiseaza numarul selectat si butoanele
|
||||
contextuale. Actioneaza pe toate randurile bifate (POST cu lista de `account_id`).
|
||||
- **Actiuni per-rand** in meniul `[ ... ]` (kebab): aceleasi verbe, pentru o singura tinta.
|
||||
- Verbele, ca stari de cont distincte (vezi §7.2): **Activeaza, Blocheaza, Arhiveaza, Sterge**.
|
||||
- `Sterge` = actiune distructiva → `hx-confirm` / dialog de confirmare obligatoriu.
|
||||
- `Blocheaza`/`Arhiveaza` reversibile → confirmare doar pe bulk (cantitate).
|
||||
- Stari vizuale ale verbelor: distructiv (`Sterge`) cu `color:var(--err)`; restul neutre `.cardlink`.
|
||||
|
||||
### 7.2 Model de stare cont (impact backend — vezi PRD riscuri)
|
||||
|
||||
Azi: `accounts.active` (0/1); "pending" = inregistrat dar `active=0`. Cele 4 verbe cer stari
|
||||
distincte care nu incap intr-un bool. Propunere (PRD o ratifica): coloana `accounts.status`
|
||||
TEXT, migrare defensiva, derivata din `active` la prima rulare:
|
||||
|
||||
```
|
||||
pending -> inregistrat, neactivat inca (active=0, status nesetat istoric)
|
||||
active -> operational (active=1)
|
||||
blocked -> suspendat reversibil (nu logheaza, worker nu trimite)
|
||||
archived -> ascuns din liste, date pastrate (read-only)
|
||||
deleted -> stergere (GDPR/L.142) (hard delete SAU soft cu purge)
|
||||
```
|
||||
|
||||
- Worker `claim_one` gate-uieste pe **status='active'** (azi pe `COALESCE(active,1)=1`) — schimbare
|
||||
semantica de pastrat compatibila: `active=1 ⇔ status='active'`.
|
||||
- **Contul dev id=1 e protejat** de Blocheaza/Arhiveaza/Sterge (cont de sistem), exact ca azi la
|
||||
activate/deactivate. Daca e selectat in bulk, e sarit, nu eroare.
|
||||
- Nota "Cont dev implicit (id=1)" din pagina **se elimina** (jargon intern, nederivabil de operator).
|
||||
Protectia ramane in cod, nu o explicam in UI.
|
||||
|
||||
> Aceasta e singura zona cu schema/backend real. Restul livrabilei e UI pur (reskin + reasezare).
|
||||
|
||||
---
|
||||
|
||||
## 8. Accesibilitate & paritate tema
|
||||
|
||||
- **AA pe light+dark** pentru orice text nou (lectia 5.3: verzi/rosii hardcodate cad sub AA). Stari
|
||||
doar prin `color-mix(... var(--card))`, niciun literal.
|
||||
- Stare = **glifa + text**, nu doar culoare (pill-urile existente respecta deja).
|
||||
- Meniul `☰`: `aria-expanded`, `aria-controls`, inchidere pe `Esc`, focus readus pe trigger.
|
||||
- `<details>` Ajutor: accesibil nativ din tastatura.
|
||||
- Checkbox-uri admin: `aria-label` per rand ("Selecteaza contul {companie}"); master = "Selecteaza tot".
|
||||
- Toate controalele >=36px zona de atins (consistent cu toggle tema / `.cardlink`).
|
||||
|
||||
## 9. Motiune
|
||||
|
||||
Minima, consistenta cu existentul: dropdown `☰` fade/translate scurt (~120ms, ca `.tab-link`
|
||||
transition). `<details>` = comportament nativ. Fara animatii noi de amploare. Respecta
|
||||
`prefers-reduced-motion` daca adaugam tranzitii (omitere la cerere).
|
||||
|
||||
## 10. Decizii utilizator (2026-06-23)
|
||||
|
||||
1. Meniu hamburger = **dropdown ancorat dreapta-sus** (nu drawer).
|
||||
2. Tab-bar = **Acasa · Mapari**; Nomenclator + Cont + Integrare + Panou admin → meniul `☰`.
|
||||
3. Mapari = **grila compacta ca Trimiteri**, toggle scurt **Auto/Manual**, link **Ajutor** in antet;
|
||||
textul repetat de pe randuri se elimina.
|
||||
4. Admin = **selectie cu bife + bara de actiuni bulk** (Activeaza/Blocheaza/Arhiveaza/Sterge) +
|
||||
actiuni per-rand; nota "cont dev implicit" **eliminata**.
|
||||
5. Sectiunea "Ajutor" de pe Acasa se **elimina**.
|
||||
6. Nomenclator capata exact aspectul tabelului Trimiteri.
|
||||
|
||||
## 11. Componente atinse (harta pentru PRD)
|
||||
|
||||
| Componenta | Fisier | Tip schimbare |
|
||||
|------------|--------|---------------|
|
||||
| Header + meniu `☰` + context auth | `base.html` (+ context render rute) | reasezare + mic backend context |
|
||||
| Tab-bar redus | `dashboard.html` | reasezare |
|
||||
| Acasa fara Ajutor | `_acasa.html` | stergere |
|
||||
| Mapari standard + toggle compact + Ajutor | `_mapari.html`, `_macros.html` | reskin UI (zero backend) |
|
||||
| Nomenclator ca Trimiteri | `_nomenclator.html` | reskin UI |
|
||||
| Admin selectie + bulk + verbe noi | `admin.html` + rute `/admin/*` | UI + **backend (status)** |
|
||||
| Model stare cont | `schema.sql`, `users.py`, worker gate | **backend + migrare** |
|
||||
159
docs/prd/prd-5.2-dryrun-valideaza.md
Normal file
159
docs/prd/prd-5.2-dryrun-valideaza.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# PRD 5.2 — Endpoint dry-run `POST /v1/prezentari/valideaza`
|
||||
|
||||
**Stare**: inchis
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Un endpoint care valideaza un payload de prezentari **exact ca** `POST /v1/prezentari`, dar
|
||||
**fara enqueue si fara efecte secundare**: intoarce verdictul (queued / needs_data / needs_mapping),
|
||||
erorile reale `[{field, message}]` si codurile nemapate, ca integratorul (ROAAUTO / soft propriu /
|
||||
punte VFP) sa-si verifice integrarea inainte sa trimita ceva real. "Magical moment" de onboarding
|
||||
(Etapa 5 — ergonomie & integrare).
|
||||
|
||||
**Invariant de corectitudine (motivul cheie de design):** dry-run-ul trebuie sa produca **acelasi
|
||||
verdict** pe care l-ar produce `POST /v1/prezentari` pe acelasi payload. Daca cele doua cai diverg,
|
||||
dry-run-ul minte — mai rau decat sa nu existe. De aceea logica de clasificare se **extrage intr-un
|
||||
helper pur partajat** folosit de AMBELE endpoint-uri (nu se duplica).
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **NU atinge coada `submissions`** — zero INSERT/UPDATE/DELETE; endpoint read-only (citeste doar
|
||||
`operations_mapping` pentru rezolvarea codurilor, scoped pe cont).
|
||||
- **NU detectie duplicat / idempotency** — nu raporteaza `idempotency_key` si nu cauta submission-uri
|
||||
identice existente (decizie utilizator 2026-06-22: doar validare + mapare). `idempotency.py` neatins.
|
||||
- **NU stocheaza si NU foloseste creds RAR** — `rar_credentials` devine optional si, daca e prezent,
|
||||
e ignorat (nu se cripteaza, nu se logheaza, nu se trimite nicaieri).
|
||||
- **NU UI / hub** — documentarea in `/integrare` (5.1) + snippet-uri ramane follow-up (decizie
|
||||
utilizator 2026-06-22). Acest PRD = strict endpoint + teste.
|
||||
- **NU atinge worker, masina de stari, schema, validation.py (regulile), nomenclator.**
|
||||
- **NU apel live la RAR** — validarea e 100% locala (replica regulilor RAR din `validation.py`).
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
### US-001: Endpoint dry-run `POST /v1/prezentari/valideaza`
|
||||
**Ca** integrator (ROAAUTO / soft propriu / VFP) **vreau** sa trimit un payload de prezentari si sa
|
||||
primesc inapoi exact erorile + verdictul pe care le-ar produce trimiterea reala, **fara** sa enqueue
|
||||
ceva sau sa am nevoie de creds RAR, **pentru ca** sa-mi validez integrarea sigur, repetabil, inainte
|
||||
de prima trimitere reala.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/mapping.py` (helper pur nou `classify_prezentare`), `app/api/v1/router.py`
|
||||
(ruta + refactor `create_prezentari` sa foloseasca helper-ul), `app/models.py` (modele request/response),
|
||||
`tests/test_validare_dryrun.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_validare_dryrun.py` —
|
||||
- `test_payload_valid_returneaza_queued` (valid → `status_estimat="queued"`, `valid=True`, `erori=[]`)
|
||||
- `test_vin_invalid_returneaza_needs_data` (VIN cu O/I/Q → `needs_data` + eroare pe `field="vin"`)
|
||||
- `test_data_viitoare_needs_data` (data in viitor → `needs_data`)
|
||||
- `test_cod_op_nemapat_returneaza_needs_mapping` (cod_op_service necunoscut → `needs_mapping` +
|
||||
`nemapate=[{cod_op_service, denumire}]`)
|
||||
- `test_mapare_existenta_rezolva_codul` (cu mapare salvata pe cont → cod_op_service rezolvat in
|
||||
`prestatii_rezolvate`, `status_estimat="queued"`)
|
||||
- `test_fara_creds_merge` (body FARA `rar_credentials` → 200)
|
||||
- `test_nu_scrie_in_coada` (`COUNT(*)` submissions inainte == dupa; zero efecte secundare)
|
||||
- `test_multi_prezentari_rezultate_per_index` (lista [valid, invalid] → 2 rezultate, `index` 0/1,
|
||||
statusuri diferite)
|
||||
- `test_shape_invalid_422` (prestatie fara `cod_prestatie` SI fara `cod_op_service` → 422 de shape,
|
||||
ca endpoint-ul real; raspunsul NU contine echo de `input`/parola)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `POST /v1/prezentari/valideaza` exista, accepta `{prezentari:[...], rar_credentials?}`,
|
||||
intoarce `{results:[{index, valid, status_estimat, erori, nemapate, prestatii_rezolvate}]}`.
|
||||
- [ ] `status_estimat` ∈ {`queued`, `needs_data`, `needs_mapping`} si coincide cu statusul pe care
|
||||
`POST /v1/prezentari` l-ar atribui pe acelasi payload + aceeasi mapare de cont (verificat prin
|
||||
helper partajat `classify_prezentare`, folosit de ambele rute).
|
||||
- [ ] `valid == (status_estimat == "queued")`.
|
||||
- [ ] `erori` = exact lista `validate_prezentare` (forma `[{field, message}]`), goala cand e curat.
|
||||
- [ ] `nemapate` = `[{cod_op_service, denumire}]` cand exista coduri interne nerezolvate; altfel `[]`.
|
||||
- [ ] `prestatii_rezolvate` arata codurile RAR rezolvate (cod_op_service mapat → cod_prestatie umplut).
|
||||
- [ ] `rar_credentials` optional; daca lipseste → 200; daca e prezent → ignorat (necriptat, nelogat).
|
||||
- [ ] Zero scriere in DB: `COUNT(*) FROM submissions` neschimbat dupa apel (read-only pe mapping).
|
||||
- [ ] Scope pe cont prin `resolve_account_id` (ca endpoint-ul real): maparea folosita la rezolvare e
|
||||
a contului cheii API (dev fara cheie → cont 1; prod → cheie obligatorie).
|
||||
- [ ] `create_prezentari` (endpoint-ul real) ramane cu comportament identic — toate testele
|
||||
existente `tests/test_api.py` raman verzi dupa refactor-ul catre helper-ul partajat.
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` pe instanta locala cu (a) payload
|
||||
valid → `queued`, (b) VIN invalid → `needs_data` + mesaj real, (c) cod_op nemapat → `needs_mapping`;
|
||||
apoi `COUNT(*)` submissions neschimbat. **Regresia de aur:** `POST /v1/prezentari` real → worker →
|
||||
`FINALIZATA` la RAR test (neatins de endpoint-ul nou).
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Divergenta dry-run vs. real** (risc principal) → mitigat prin helper pur partajat `classify_prezentare`
|
||||
folosit de ambele rute + AC explicit + testele `test_api.py` care lock-uiesc calea reala dupa refactor.
|
||||
- **Refactor al caii reale** (`create_prezentari` adopta helper-ul) ar putea schimba subtil comportamentul
|
||||
→ mitigat: testele existente `test_api.py` sunt contractul; raman verzi = comportament identic. Helper-ul
|
||||
intoarce exact aceleasi `status` + `rar_error` ca azi (queued / needs_data + erori JSON / needs_mapping +
|
||||
unmapped JSON / needs_mapping + auto_send note).
|
||||
- **Scurgere de creds** prin noul model → mitigat: `rar_credentials` optional, ignorat, `repr=False`
|
||||
pastrat; handler-ul global 422 din `main.py` deja dropeaza `input`/`ctx` (no-echo parola) — acoperit de
|
||||
`test_shape_invalid_422`.
|
||||
- **Asteptare gresita: "valideaza" = duplicat-check** → documentat ca Non-Goal in raspuns/docstring;
|
||||
fara `idempotency_key` in raspuns ca sa nu sugereze dedup.
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
|
||||
|
||||
- **Continut raspuns** — REZOLVAT: doar validare + mapare (fara idempotency/duplicat). [user 2026-06-22]
|
||||
- **Hub /integrare** — REZOLVAT: amanat; 5.2 = endpoint + teste. [user 2026-06-22]
|
||||
- `auto_send=0` pe un cod mapat: pe calea reala devine `needs_mapping` cu nota "review manual". Dry-run-ul
|
||||
raporteaza acelasi `status_estimat="needs_mapping"` (consistenta cu real). Confirmat ca acceptabil —
|
||||
fara camp separat pentru cazul auto_send (ar fi scope creep). [decizie de plan, acceptata implicit]
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] ← singur story, fara dependente. Un worker (sau lead direct, livrabila mica — ROADMAP §5.5).
|
||||
```
|
||||
|
||||
Livrabila mica: poate rula fara `TeamCreate` (un singur worker Sonnet TDD), dar VERIFY in context curat
|
||||
+ writeback raman obligatorii (ROADMAP §5.5).
|
||||
|
||||
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
|
||||
|
||||
**CEO (valoare/scope) — PASS.** Problema corecta, calea cea mai directa (reuse pur
|
||||
`validation.py`+`mapping.py`, zero logica de domeniu noua). Inversiune ("ce-l face sa esueze?"):
|
||||
divergenta dry-run vs. real — neutralizata prin helper-ul partajat `classify_prezentare` +
|
||||
lock-ul `test_api.py`. Scope minim corect; singura "expansiune" (extragerea clasificatorului) e
|
||||
ceruta de corectitudine, nu scope creep. **Risc flagat (deferare constienta):** descoperibilitate
|
||||
— un endpoint pe care integratorii nu-l gasesc nu-si livreaza valoarea pana cand un follow-up il
|
||||
expune in `/integrare`. Acceptat de utilizator (amanat); valoarea 5.2 se realizeaza la follow-up.
|
||||
|
||||
**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, fara atingere schema/worker/idempotenta.
|
||||
Lista de teste e completa (happy path + fiecare ramura de eroare + fara-creds + multi-prezentare cu
|
||||
index + shape-422 fara echo + aserctia critica zero-efecte `COUNT(*)` inainte==dupa). Singurul risc
|
||||
pe calea de aur = refactor-ul `create_prezentari` pe helper-ul partajat; contractul de blocare =
|
||||
suita `test_api.py` existenta (ramane verde = comportament identic). Read-only → fara logging nou
|
||||
(consistent cu GET-urile existente); in prod cere cheie prin `resolve_account_id` (fara suprafata noua de abuz).
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe canal API). Lipseste pana la VERIFY.
|
||||
|
||||
Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
|
||||
|
||||
**1. Suita — PASS.** `python3 -m pytest -q` → 577 passed (226 warnings preexistente Starlette/templating).
|
||||
`pytest tests/test_validare_dryrun.py tests/test_api.py -q` → 14 passed (9 dry-run TDD + 5 regresia caii reale).
|
||||
|
||||
**2. Acceptance criteria US-001 — toate PASS.** Endpoint exista cu raspunsul `{results:[{index,valid,
|
||||
status_estimat,erori,nemapate,prestatii_rezolvate}]}`. `status_estimat` coincide cu `POST /v1/prezentari`
|
||||
prin helper-ul partajat `classify_prezentare` apelat de AMBELE rute (`router.py` create + valideaza, acelasi
|
||||
`load_mapping_meta`). `valid == (status=="queued")`. `erori`=lista `validate_prezentare`. `nemapate`=
|
||||
`[{cod_op_service,denumire}]`. `rar_credentials` optional + ignorat (`encrypt_creds` absent din ruta;
|
||||
parola din body NU apare in raspuns). Zero scriere DB (COUNT(*) submissions=0 dupa 3 apeluri). Scope prin
|
||||
`resolve_account_id`. `create_prezentari` identic (test_api.py verde).
|
||||
|
||||
**3. E2E canal API (TestClient + DB temp, verificare directa SQLite) — PASS.** (a) valid+creds → queued,
|
||||
valid=true, erori=[], fara leak creds; (b) VIN cu O/I/Q → needs_data + eroare reala pe `vin`; (c) cod_op
|
||||
nemapat → needs_mapping + nemapate populat. Dupa toate: `GET /v1/prezentari`=0, DB COUNT(*)=0.
|
||||
|
||||
**4. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari` enqueue-aza corect
|
||||
(200, queued, COUNT(*)=1) + test_api.py verde. Flux live RAR (worker→FINALIZATA pe RAR test) NEPROBAT —
|
||||
lipsesc secretele in mediu (`AUTOPASS_CREDS_KEY` nesetat, fara creds RAR test/`--send`). NU e FAIL al 5.2:
|
||||
endpoint-ul nou e read-only, nu atinge worker/coada/schema. Documentat, nu inventat.
|
||||
256
docs/prd/prd-5.3-light-dark-mode.md
Normal file
256
docs/prd/prd-5.3-light-dark-mode.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# PRD 5.3 — Light/Dark mode (comutator tema persistat)
|
||||
|
||||
**Stare**: inchis
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Dashboard-ul web e azi **doar dark** (paleta fixa in `:root`). Adaugam o tema **light** si un
|
||||
**comutator in header** care persista alegerea utilizatorului. Service-urile care vin din Visual
|
||||
FoxPro / soft propriu lucreaza des in birouri luminoase si pe monitoare unde dark-mode obositor sau
|
||||
greu de citit la videoproiector — un toggle light/dark e o cerinta de ergonomie de baza (Etapa 5).
|
||||
|
||||
CSS-ul **e deja pe variabile** (`--bg`, `--card`, `--ink`, `--muted`, `--line`, `--ok`, `--warn`,
|
||||
`--err`, `--accent` in `base.html`). Tema light = un bloc `[data-theme="light"]` care **suprascrie
|
||||
aceleasi variabile** cu o paleta deschisa. Efort mic, zero logica de domeniu, zero backend.
|
||||
|
||||
**Invariant de design (motivul cheie):** comutarea nu trebuie sa **palpaie** (FOUC — flash of
|
||||
unstyled / wrong-theme content). Tema se aplica **inainte de primul paint** printr-un script inline
|
||||
mic in `<head>`, care citeste preferinta din `localStorage` si seteaza `data-theme` pe `<html>`
|
||||
sincron, inainte ca `<body>` sa randeze. Fara asta, fiecare incarcare de pagina ar clipi dark→light.
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **NU backend / cookie / ruta noua.** Persistenta = `localStorage` pur client-side (roadmap zice
|
||||
"cookie/localStorage" — alegem localStorage: zero suprafata server + anti-FOUC prin scriptul din
|
||||
`<head>`). Nu se atinge `routes.py`, `auth.py`, sesiunea, baza de date.
|
||||
- **NU redesign de paleta dark.** Tema dark ramane **identica la octet** cu cea de azi (default
|
||||
pastrat); adaugam doar varianta light + un toggle. Nicio culoare dark existenta nu se schimba.
|
||||
- **NU teme multiple / personalizate / culoare de accent reglabila.** Doar doua: `dark` (default)
|
||||
si `light`.
|
||||
- **NU atinge worker, masina de stari, idempotenta, mapping, schema, validation.py, API.** Strict
|
||||
`app/web/templates/base.html` (+ eventual un test de template).
|
||||
- **NU restilizeaza fragmentele HTMX.** Toate fragmentele (`_*.html`) mostenesc variabilele din
|
||||
`base.html` — comuta automat cu tema. (Conditie: zero culori hardcodate care sa nu adapteze —
|
||||
vezi US-001 pentru suprafetele care azi au fundal hardcodat.)
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Ambele stories ating **acelasi fisier** (`base.html`) → **secventiale**, un singur worker (sau
|
||||
> lead direct, livrabila mica — ROADMAP §5.5). NU se paralelizeaza (regula fisier-comun §5.5).
|
||||
|
||||
### US-001: Tema light (paleta + suprafete theme-aware)
|
||||
**Ca** utilizator al dashboard-ului **vreau** o paleta light corecta si lizibila **pentru ca** sa pot
|
||||
folosi gateway-ul confortabil in birou luminos / la videoproiector, fara contrast slab sau zone care
|
||||
raman intunecate.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/base.html` (bloc CSS `[data-theme="light"]` + conversia
|
||||
suprafetelor cu fundal hardcodat la variabile), `tests/test_tema.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_tema.py` —
|
||||
- `test_paleta_light_definita` — HTML-ul `GET /login` (sau dashboard) contine un selector
|
||||
`[data-theme="light"]` care redefineste `--bg`, `--card`, `--ink`, `--muted`, `--line`.
|
||||
- `test_dark_ramane_default` — `:root` contine inca paleta dark exacta (`--bg:#0f1115`,
|
||||
`--card:#181b22`, `--ink:#e6e9ef`) → default neschimbat.
|
||||
- `test_suprafete_fara_fundal_hardcodat` — fundalurile de stare (banner eroare/warn, flash) NU mai
|
||||
folosesc literal hex dark fix (`#241a1a`, `#201c0f`, `#16241c`) ci variabile/`color-mix` ce
|
||||
adapteaza la tema (asertie pe absenta literalilor in `<style>`).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Exista un bloc `[data-theme="light"]` in `<style>` care suprascrie cel putin
|
||||
`--bg`, `--card`, `--ink`, `--muted`, `--line` cu o paleta deschisa (fundal deschis, text
|
||||
inchis). Contrastul text/fundal ≥ WCAG AA (4.5:1 pentru `--ink` pe `--bg` si pe `--card`).
|
||||
- [ ] Paleta **dark** din `:root` ramane neschimbata la octet (default) — comportament identic cu azi
|
||||
cand nu exista preferinta salvata si OS-ul nu cere light.
|
||||
- [ ] Suprafetele cu fundal azi hardcodat dark (`.banner`, `.banner.warn`, `.flash`, eventual
|
||||
`.drop-zone.drag-over`) sunt facute theme-aware (variabile sau `color-mix` peste paleta), astfel
|
||||
incat in light arata corect (fundal deschis colorat, nu pata intunecata).
|
||||
- [ ] Culorile semantice (`--ok`/`--warn`/`--err`/`--accent`) raman lizibile pe fundal light
|
||||
(ajustate daca e nevoie pentru contrast ≥ AA pe text mic).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: browser pe `/` cu `document.documentElement.dataset.theme="light"` setat manual →
|
||||
fundal deschis, text inchis lizibil, banner/flash/pill citibile, tabele cu linii vizibile, niciun
|
||||
text "invizibil" (acelasi ton ca fundalul).
|
||||
|
||||
### US-003: Suprafete theme-aware si in fragmentele HTMX (fix VERIFY r1)
|
||||
**Ca** utilizator pe light mode **vreau** ca bannerele de eroare/warn/flash din fragmentele HTMX sa
|
||||
fie lizibile **pentru ca** azi raman pete intunecate cu text invizibil (defect prins la VERIFY r1).
|
||||
|
||||
- **Depinde de**: US-001 (extinde aceeasi conversie theme-aware dincolo de `base.html`)
|
||||
- **Motiv**: US-001 a convertit la `color-mix` DOAR `base.html`; aceleasi fundaluri hardcodate dark
|
||||
(`#241a1a` err, `#201c0f` warn) traiesc ca **inline-style** in 7 fragmente `_*.html` (10 aparitii)
|
||||
— randate in dashboard, deci vizibile in light ca text invizibil. Testul US-001 scana doar `<style>`
|
||||
din base.html → trecea vacuu.
|
||||
- **Fisiere**: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`,
|
||||
`_preview_rand.html`, `_trimitere_detaliu.html`, `_mapcoloane.html` (toate `app/web/templates/`),
|
||||
`tests/test_tema.py` (extins)
|
||||
- **Test intai (RED)**: `tests/test_tema.py` —
|
||||
- `test_fragmente_fara_fundal_hardcodat` — scaneaza TOATE fisierele `app/web/templates/_*.html`
|
||||
(continutul brut) si asigura ca niciunul nu contine literalii `#241a1a`, `#201c0f`, `#16241c`.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Niciun fisier din `app/web/templates/` (inclusiv fragmentele) nu mai contine literalii hex
|
||||
dark-fix `#241a1a`/`#201c0f`/`#16241c`; fundalurile folosesc `color-mix` peste paleta
|
||||
(`var(--err)`/`var(--warn)`/`var(--ok)` 12% peste `var(--card)`), exact ca in `base.html`.
|
||||
- [ ] In light mode bannerele/flash-urile de stare au fundal deschis colorat cu text lizibil
|
||||
(verificat E2E pe dashboard: banner "Cont in asteptare de activare" nu mai e cutie neagra).
|
||||
- [ ] In dark mode aspectul ramane practic identic cu azi (color-mix peste `--card` dark dă aproape
|
||||
aceeasi nuanta).
|
||||
- [ ] Testul de protectie scaneaza fragmentele, nu doar `base.html` (lacuna r1 inchisa).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: dashboard pe light mode → banner pending-account + orice flash de eroare/warn
|
||||
lizibile (fundal deschis); comuta la dark → aspect neschimbat.
|
||||
|
||||
### US-002: Comutator tema in header + persistenta + anti-FOUC
|
||||
**Ca** utilizator **vreau** un buton in header care comuta light/dark si imi tine minte alegerea
|
||||
**pentru ca** sa nu re-comut la fiecare incarcare si sa nu vad un flash de tema gresita.
|
||||
|
||||
- **Depinde de**: US-001 (vizual; tehnic ating acelasi fisier → oricum secvential)
|
||||
- **Fisiere**: `app/web/templates/base.html` (script inline anti-FOUC in `<head>` + buton toggle in
|
||||
`<header>` + handler de comutare/persistenta), `tests/test_tema.py` (extins, acelasi fisier ca US-001)
|
||||
- **Test intai (RED)**: `tests/test_tema.py` —
|
||||
- `test_script_antifouc_in_head_inainte_de_style` — `<head>` contine un `<script>` care citeste
|
||||
`localStorage` cheia `theme` si seteaza `document.documentElement` `data-theme` **inainte** de
|
||||
tag-ul `<style>` (pozitie in HTML: index script < index `<style>`).
|
||||
- `test_buton_toggle_in_header_cu_eticheta` — `<header>` contine un control de comutare cu
|
||||
`aria-label`/`title` descriptiv (ex. "Comuta tema") si un `id`/atribut stabil pentru handler.
|
||||
- `test_toggle_pe_login_si_dashboard` — butonul apare si pe `/login` (neautentificat) si pe dashboard
|
||||
(ambele extind `base.html`).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Script inline in `<head>`, plasat **inaintea** `<style>`, citeste preferinta:
|
||||
`localStorage.theme` daca exista, altfel `prefers-color-scheme` (fallback final: `dark`), si
|
||||
seteaza `data-theme` pe `<html>` sincron → **fara FOUC** la incarcare/reload.
|
||||
- [ ] Buton de comutare in `<header>` (langa `env`/`version`), cu `aria-label` descriptiv, atins
|
||||
usor (≥ 36px zona de atins), care comuta `data-theme` light↔dark **fara reload**.
|
||||
- [ ] La comutare se scrie `localStorage.theme` → alegerea persista peste reload si peste navigari
|
||||
(deep-link `?tab=`), inclusiv pe paginile neautentificate (login/signup).
|
||||
- [ ] Butonul reflecta starea curenta (eticheta/iconita arata ce face: "→ light" cand e dark si
|
||||
invers), accesibil la tastatura (e un `<button>`).
|
||||
- [ ] Functioneaza pe toate cele 4 pagini top-level (login, signup, dashboard, admin) — toate extind
|
||||
`base.html`, deci o singura implementare le acopera.
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E** (Playwright MCP / `/browse`): pe `/login` apoi pe dashboard — (a) click toggle →
|
||||
paleta comuta instant (fundal/text), (b) reload pagina → tema aleasa **persista** (citeste din
|
||||
localStorage, fara flash de tema veche), (c) comuta inapoi → persista invers, (d) zero erori in
|
||||
consola, (e) fara FOUC vizibil la reload (tema corecta din primul frame).
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **FOUC** (risc principal) → mitigat prin scriptul inline din `<head>` ASAMBLAT INAINTE de `<style>`,
|
||||
care seteaza `data-theme` sincron, pre-paint. Verificat E2E (reload nu clipeste).
|
||||
- **Suprafete hardcodate raman dark in light** (banner/flash cu hex fix) → mitigat de US-001 (asertie
|
||||
de test pe absenta literalilor + theme-aware via variabile/`color-mix`).
|
||||
- **Contrast slab in light** (text gri pe alb, accent palid) → mitigat: AC explicit ≥ WCAG AA pe
|
||||
`--ink`/`--muted` peste `--bg`/`--card`; verificare E2E vizuala (text lizibil).
|
||||
- **Regresie pe dark** (refactor accidental al paletei existente) → mitigat: `test_dark_ramane_default`
|
||||
lock-uieste hex-urile dark exacte in `:root`; Non-Goal explicit "dark identic la octet".
|
||||
- **localStorage indisponibil** (mod privat strict / dezactivat) → script defensiv: `try/catch` in
|
||||
jurul citirii/scrierii; cade pe default (dark/OS) fara sa arunce. (AC implicit: zero erori consola.)
|
||||
- **Acelasi fisier in 2 stories** (`base.html`) → NU se paralelizeaza; un singur worker secvential
|
||||
(US-001 apoi US-002). Notat in §6.
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
|
||||
|
||||
- **Default pentru utilizator nou (fara preferinta salvata)** — REZOLVAT: **OS-aware cu fallback dark**
|
||||
(onoreaza `prefers-color-scheme`; cand OS-ul nu cere light → `dark`, look-ul actual). [user 2026-06-22]
|
||||
- **Persistenta** — REZOLVAT: **`localStorage`** (client-only, anti-FOUC prin script in `<head>`, zero
|
||||
backend; nu atinge `routes.py`/sesiune). [user 2026-06-22]
|
||||
- **Aspect comutator** — REZOLVAT: **iconita soare/luna + `aria-label`** descriptiv, compact in header.
|
||||
[user 2026-06-22]
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] → [US-002] ← SECVENTIAL (acelasi fisier base.html). Un singur worker (sau lead direct).
|
||||
```
|
||||
|
||||
Livrabila mica, un singur fisier de productie atins (`base.html`): poate rula fara `TeamCreate`
|
||||
(un worker Sonnet TDD, ambele stories secvential). VERIFY in context curat + writeback raman
|
||||
obligatorii (ROADMAP §5.5).
|
||||
|
||||
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
|
||||
|
||||
> Se completeaza la PLAN inainte de aprobare. CEO + Eng obligatorii; Design — DA (atinge UI).
|
||||
|
||||
**CEO (valoare/scope) — PASS.** Cerinta directa de ergonomie din Etapa 5 (decizie utilizator
|
||||
2026-06-22), efort mic peste o fundatie deja pregatita (CSS pe variabile). Calea cea mai scurta:
|
||||
reuse `:root` + override `[data-theme]`, zero backend. Inversiune ("ce-l face inutil?"): FOUC la
|
||||
incarcare (face produsul sa para buggy) — neutralizat prin scriptul anti-FOUC din `<head>`; si
|
||||
suprafetele hardcodate care raman dark in light (arata stricat) — neutralizate prin theme-aware in
|
||||
US-001. Scope minim corect (doua teme, fara personalizare). Niciun scope creep.
|
||||
|
||||
**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, un singur fisier de productie, zero
|
||||
atingere de backend/schema/worker. Testele acopera contractul de template (paleta light prezenta,
|
||||
dark neschimbat, suprafete theme-aware, script anti-FOUC pozitionat corect, toggle prezent +
|
||||
accesibil + pe paginile neautentificate); comportamentul vizual + persistenta + anti-FOUC raman pe
|
||||
E2E browser (corect — nu se pot prinde la TestClient). Risc unic real = FOUC, prins doar in browser
|
||||
→ E2E explicit cu reload. `localStorage` defensiv (try/catch) acoperit ca AC zero-erori.
|
||||
|
||||
**Design — PASS (cu note).** Atinge UI direct. Paleta light trebuie sa respecte contrast WCAG AA
|
||||
(AC explicit). Comutatorul: iconita soare/luna + `aria-label`, plasat in header langa env/version,
|
||||
zona de atins ≥ 36px (consistent cu `.cardlink` existent). Suprafetele semantice (ok/warn/err) sa
|
||||
ramana distincte si lizibile pe light, nu doar inversate. Tranzitia de comutare poate fi instant
|
||||
(fara animatie) ca sa nu para lenta; daca se adauga `transition` pe culori, scurta (≤ 150ms, ca
|
||||
restul UI-ului). De confirmat vizual la VERIFY E2E.
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
|
||||
### Runda 1 (2026-06-22) — FAIL
|
||||
|
||||
Verificator independent (context curat, rol qa-only). **VERDICT: FAIL** (un blocker, US-001).
|
||||
|
||||
- **Suita — PASS.** `python3 -m pytest -q` → 583 passed; `tests/test_tema.py` → 6 passed.
|
||||
- **US-002 (toggle + persistenta + anti-FOUC) — PASS integral (E2E browser).** Toggle pe
|
||||
login/signup/dashboard; comutare instant (bg `#f6f7f9`↔`#0f1115`, aria-label + iconita comuta);
|
||||
persistenta `localStorage.theme` doar la click; reload pastreaza tema fara FOUC; OS-aware fara
|
||||
scriere (load simplu → `localStorage.theme`=null); zero erori consola (doar 404 favicon preexistent).
|
||||
- **US-001 (paleta light + suprafete theme-aware) — FAIL.** Paleta light + dark-neschimbat + `base.html`
|
||||
color-mix = PASS, DAR suprafetele de stare raman pete intunecate cu **text invizibil** in light:
|
||||
literalii dark `#241a1a`/`#201c0f` NU au fost convertiti in **fragmentele HTMX** (10 aparitii, 7
|
||||
fisiere: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`, `_preview_rand.html`,
|
||||
`_trimitere_detaliu.html`, `_mapcoloane.html`) — inline-style, randate in dashboard. Dovada: banner
|
||||
"Cont in asteptare de activare" = cutie `rgb(32,28,15)` pe fundal `#f6f7f9`. Testul `test_suprafete_
|
||||
fara_fundal_hardcodat` scana DOAR `<style>` din base.html → verde inselator (vacuu).
|
||||
- **Regresie de aur — PASS.** Pur frontend; worker/coada/API/schema neatinse (worker a procesat o
|
||||
orfana `sent idPrezentare=68801` la pornire → pipeline send functional, neafectat de 5.3).
|
||||
|
||||
**Remediu (US-003, adaugat in §3):** muta literalii la `color-mix` in cele 7 fragmente + extinde testul
|
||||
sa scaneze fragmentele. Re-VERIFY cu subagent NOU dupa fix.
|
||||
|
||||
### Runda 2 (2026-06-22) — PASS
|
||||
|
||||
Verificator independent NOU (context curat, rol qa-only), dupa fix US-003. **VERDICT GLOBAL: PASS.**
|
||||
|
||||
- **Suita — PASS.** `python3 -m pytest -q` → 584 passed; `tests/test_tema.py` → 7 passed (incl. noul
|
||||
`test_fragmente_fara_fundal_hardcodat`).
|
||||
- **Anti-regresie protectie — PASS.** `grep -rn -E '#201c0f|#241a1a|#16241c' app/web/templates/` →
|
||||
GOL. Testul scaneaza fisierele `_*.html` de pe disc (`Path.glob`), nu doar base.html → lacuna r1 inchisa.
|
||||
- **US-001/US-003 (light lizibil) — PASS (E2E, dovada cheie).** Cont inactive fortat → banner
|
||||
"Cont in asteptare de activare" din `_status.html` in light: `background` ≈ `rgb(246,234,225)` (crema
|
||||
deschis), text `rgb(26,29,36)` (contrast ~13:1 ≫ AA), border amber. Cutia neagra `rgb(32,28,15)` din
|
||||
r1 a DISPARUT. Screenshot confirma caseta peach cu text negru lizibil. Scan al tuturor suprafetelor
|
||||
vizibile in light: zero zone genuin intunecate. Dark mode: aspect practic neschimbat.
|
||||
- **US-002 — PASS (re-confirmat).** Toggle instant; `localStorage.theme` doar la click; aria-label se
|
||||
inverseaza; persista peste reload in ambele sensuri; anti-FOUC (script in `<head>` inainte de `<style>`);
|
||||
toggle pe `/login` + dashboard; zero erori JS de tema in consola.
|
||||
- **Regresie de aur — PASS.** Diff strict frontend (`base.html` + 7 fragmente + `tests/test_tema.py`);
|
||||
worker/coada/API/schema/mapping NEATINSE. Flux LIVE RAR catre RAR test NEPROBAT (creds key efemera;
|
||||
send neatins de 5.3) — NEPROBAT, nu FAIL.
|
||||
- **Nota cleanup:** verificatorul a lasat un cont de test inactive (id=5, verifyr2@test.com) in DB-ul
|
||||
de test efemer folosit pentru a forta bannerul — inofensiv (date de test, mediu test).
|
||||
|
||||
### CLOSE — `/code-review` high (2026-06-22)
|
||||
|
||||
1 finding real reparat: in paleta light `--ok:#16a34a` (green-600) folosit ca text (`.s-sent`/`.s-ok`,
|
||||
bife verzi din bara de stare) pe `--card:#ffffff` dadea contrast ~3.3:1 — sub WCAG AA 4.5:1 pentru text
|
||||
mic, incalcand AC-ul US-001. Reparat → `--ok:#15803d` (green-700, ~5.0:1, trece AA); paleta dark
|
||||
neatinsa. Restul semanticelor light trec deja AA (--err 6.2:1, --warn 5.0:1, --accent 5.1:1). Schimbare
|
||||
de valoare CSS (fara comportament) → fara re-VERIFY. Refutate: suport `color-mix` (universal pe browsere
|
||||
moderne, audienta B2B); duplicarea inline a `color-mix` in fragmente (precede acest diff, candidat de
|
||||
cleanup viitor — macro/clase `.flash.err`/`.flash.warn`). 584 teste pass.
|
||||
395
docs/prd/prd-5.4-erori-3-niveluri.md
Normal file
395
docs/prd/prd-5.4-erori-3-niveluri.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# PRD 5.4 — Erori pe 3 niveluri (problema + cauza + fix) pe API si UI
|
||||
|
||||
**Stare**: inchis
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Fiecare eroare pe care o vede un integrator (canal API) sau un service-auto (UI web) sa raspunda la
|
||||
trei intrebari, in loc de una singura:
|
||||
|
||||
1. **Problema** — ce s-a intamplat (categorie umana, scurta). *"VIN invalid."*
|
||||
2. **Cauza** — de ce, specific. *"VIN-ul are 16 caractere; RAR cere exact 17."*
|
||||
3. **Fix** — ce sa faci acum. *"Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule, fara O/I/Q."*
|
||||
|
||||
Motivul (lentila DX, Etapa 5): erorile plate ("Fisier nerecunoscut", "cheie API invalida", "VIN invalid")
|
||||
**transfera incertitudinea catre utilizator** — care fie ghiceste, fie deschide un tichet de suport. Cele
|
||||
trei niveluri inchid bucla la sursa: mai putine tichete, integrare self-service.
|
||||
|
||||
**Invariant de corectitudine (motivul cheie de design):** cele trei niveluri pentru un anumit cod de
|
||||
eroare se definesc **o singura data**, intr-un **catalog central pur** (`app/errors.py`), consumat de
|
||||
**toate** suprafetele (API + UI + worker). Daca textul s-ar duplica pe canale, API si UI ar putea
|
||||
diverge — un cod ar spune un lucru in JSON si altul in dashboard. Catalogul unic face imposibila
|
||||
divergenta (acelasi pattern care a facut 5.2 corect: o singura sursa partajata, nu doua copii).
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **NU acoperim login / signup / CSRF / auth 401** (decizie utilizator 2026-06-22: focus pe fluxul de
|
||||
declarare). Aceste suprafete sunt edge / dev si raman mesaje plate. `auth_routes.py`, `csrf.py`,
|
||||
handler-ele `LoginRequired`/`AdminRequired`/`CsrfError` din `main.py` — NEATINSE.
|
||||
- **NU breaking change pe API** (decizie utilizator 2026-06-22: aditiv). Pastram campurile existente
|
||||
(`field`, `message`, `error`, `type`/`loc`/`msg`) si **ADAUGAM** `cod`, `problema`, `cauza`, `fix`.
|
||||
Clientii vechi (ROAAUTO / soft propriu integrat la 5.1/5.2) nu se strica; cei noi pot afisa 3 niveluri.
|
||||
- **NU schema noua** — `submissions.rar_error` e deja TEXT si stocheaza JSON; doar imbogatim continutul.
|
||||
Zero migrare.
|
||||
- **NU apel live nou la RAR** — pentru erorile RAR 400 imbracam mesajul RAR existent (passthrough ca
|
||||
`cauza`) intr-un invelis 3-niveluri; nu schimbam clasificarea transient/terminal a worker-ului.
|
||||
- **NU schimbam regulile de validare** — `validate_prezentare` valideaza exact aceleasi conditii;
|
||||
doar ataseaza `cod` + nivelele la fiecare eroare existenta.
|
||||
- **NU schimbam masina de stari / idempotenta / mapping-rezolvarea / nomenclatorul.**
|
||||
- **NU traducere i18n** — romana, ca tot proiectul.
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
### US-001: Catalog central de erori (`app/errors.py`) — DONE (588 teste)
|
||||
**Ca** dezvoltator al gateway-ului **vreau** o singura sursa de adevar care mapeaza fiecare cod de
|
||||
eroare la (problema, fix), cu un helper care construieste obiectul de eroare 3-niveluri, **pentru ca**
|
||||
API-ul, UI-ul si worker-ul sa nu poata diverge in ce explica utilizatorului.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/errors.py` (modul pur nou), `tests/test_errors.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_errors.py` —
|
||||
- `test_catalog_complet` — fiecare intrare are `problema` + `fix` ne-goale (string).
|
||||
- `test_eroare_construieste_3niveluri` — `eroare("VIN_FORMAT", field="vin", cauza="...")` intoarce
|
||||
dict cu cheile `{field, cod, problema, cauza, fix, message}`, `cod=="VIN_FORMAT"`, `problema`/`fix`
|
||||
luate din catalog, `cauza` cea data.
|
||||
- `test_message_back_compat` — cand `cauza` e dat, `message == cauza` (alias pentru clientii vechi).
|
||||
- `test_cod_necunoscut_ridica` — `eroare("INEXISTENT")` ridica `KeyError`/`ValueError` (nu inventeaza
|
||||
text gol — drift prins la dezvoltare).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `app/errors.py` pur (fara import DB/HTTP), expune `CATALOG: dict[str, dict]` (cod → {problema, fix})
|
||||
si `eroare(cod, *, field=None, cauza=None) -> dict`.
|
||||
- [ ] Obiectul de eroare are exact cheile `{field, cod, problema, cauza, fix, message}`; `message`
|
||||
(back-compat) `== cauza` cand `cauza` e dat, altfel `== problema`.
|
||||
- [ ] CATALOG contine codurile pentru toate suprafetele in scop (validare continut, mapare op→cod,
|
||||
RAR, import) — vezi lista din §4. Fiecare intrare are `problema` + `fix` ne-goale.
|
||||
- [ ] **`fix` e specific si actionabil** (finding CEO): numeste un loc/o actiune concreta (ex. "talon,
|
||||
pozitia E", "tab-ul Mapari", "salveaza ca .csv UTF-8"), NU boilerplate generic ("verifica datele").
|
||||
Un fix generic = eroare plata mai lunga; testul de calitate al livrabilei. (Verificat la review uman.)
|
||||
- [ ] `eroare` cu cod absent din CATALOG ridica eroare (nu intoarce text gol).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: unitar (modul pur) — fara canal.
|
||||
|
||||
### US-002: Validarea de continut emite 3 niveluri (`validation.py`) — DONE
|
||||
**Ca** integrator API **vreau** ca fiecare eroare de validare (VIN/nr/data/odometru/prestatii/b64) sa
|
||||
spuna problema + cauza + fix, **pentru ca** sa corectez payload-ul fara sa ghicesc formatul cerut.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/validation.py`, `tests/test_validation.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_validation.py` —
|
||||
- `test_vin_invalid_are_3niveluri` — VIN cu O/I/Q → eroare cu `cod=="VIN_FORMAT"`, `problema`,
|
||||
`fix` ne-goale, `field=="vin"`, `message` pastrat (back-compat).
|
||||
- `test_data_prea_veche_cod` / `test_data_viitor_cod` / `test_data_format_cod` — coduri distincte.
|
||||
- `test_odometru_initial_lipsa_cod` / `test_odometru_ordine_cod` — coduri distincte.
|
||||
- `test_prestatii_goale_cod`, `test_b64_invalid_cod`.
|
||||
- `test_back_compat_field_message` — fiecare eroare are inca `field` + `message` (forma veche
|
||||
pastrata pentru clientii existenti).
|
||||
- `test_toate_codurile_in_catalog` — fiecare `cod` emis de `validate_prezentare` exista in `CATALOG`.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `validate_prezentare` intoarce erori cu `{field, message, cod, problema, cauza, fix}` (aditiv —
|
||||
`field` + `message` neschimbate la octet fata de azi).
|
||||
- [ ] Fiecare regula are un `cod` stabil (vezi lista §4); textul (problema/fix) vine din `errors.eroare`.
|
||||
- [ ] **Byte-compat** (finding Eng): `cauza` = mesajul existent verbatim (eventual + context specific
|
||||
precum lungimea VIN gasita); `message` ramane EXACT string-ul de azi → testele existente care
|
||||
compara `message` raman verzi fara modificari.
|
||||
- [ ] Toate testele existente (`test_validation.py`, `test_api.py`, `test_validare_dryrun.py`) raman
|
||||
verzi (forma veche `field`/`message` intacta).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` cu VIN invalid → `erori[0]` are cele
|
||||
3 niveluri + `field`/`message` vechi.
|
||||
|
||||
### US-003: Propagare 3 niveluri prin mapare + raspuns API (`mapping.py`, `router.py`) — DONE
|
||||
**Ca** integrator API **vreau** ca raspunsul `/valideaza` si motivul stocat (`rar_error`) sa transporte
|
||||
cele 3 niveluri pentru validare SI pentru coduri nemapate / auto-send oprit, **pentru ca** verdictul sa
|
||||
fie la fel de explicit indiferent de ramura (needs_data / needs_mapping).
|
||||
|
||||
- **Depinde de**: US-001, US-002
|
||||
- **Fisiere**: `app/mapping.py`, `app/api/v1/router.py`, `app/models.py`, `tests/test_mapping.py`,
|
||||
`tests/test_validare_dryrun.py` (~5 fisiere)
|
||||
- **Test intai (RED)**:
|
||||
- `test_mapping.py::test_unmapped_are_3niveluri` — cod_op_service necunoscut → `classify_prezentare`
|
||||
produce `needs_mapping` cu `cod=="COD_NEMAPAT"` + problema/cauza (codul concret)/fix in structura.
|
||||
- `test_mapping.py::test_auto_send_oprit_3niveluri` — mapare cu `auto_send=0` → `cod=="AUTO_SEND_OPRIT"`
|
||||
+ 3 niveluri.
|
||||
- `test_mapping.py::test_needs_data_pass_through` — erorile de validare imbogatite trec neatinse prin
|
||||
`classify_prezentare` in `rar_error`.
|
||||
- `test_validare_dryrun.py::test_erori_au_3niveluri` — `/valideaza` cu VIN invalid → `erori[i]` are
|
||||
`cod/problema/cauza/fix`; cu cod_op nemapat → `nemapate` carry 3 niveluri.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `classify_prezentare` pastreaza erorile de validare imbogatite (pass-through) in `rar_error`.
|
||||
- [ ] Ramura `needs_mapping` (cod nemapat) si nota `auto_send=0` se construiesc prin `errors.eroare`
|
||||
(3 niveluri), nu string-uri ad-hoc.
|
||||
- [ ] **`rar_error` stocat = SUPERSET al formei de azi** (finding Eng critic): pastreaza structura
|
||||
veche (`needs_data` → array `[{field,message,...}]`; `needs_mapping` → `{unmapped:[...], ...}`),
|
||||
ADAUGA cheile 3-niveluri. Asa `labels.motiv_uman` actual ramane functional intre Val 3 si Val 4
|
||||
(nu se strica pana e actualizat in US-006) si nu e nevoie de migrare. Aplica principiul aditiv si
|
||||
la datele stocate, nu doar la API.
|
||||
- [ ] Raspunsul `/valideaza` (`erori`, `nemapate`) include `cod/problema/cauza/fix` aditiv; modelele
|
||||
din `models.py` accepta cheile noi fara a respinge (verifica: tipul `erori`/`nemapate` e permisiv
|
||||
sau extins; nu schema stricta care respinge chei in plus).
|
||||
- [ ] **Teste subset, nu egalitate exacta** (finding Eng): testele existente care comparau `==` un dict
|
||||
de eroare se actualizeaza la asertii de subset (comportament identic, doar chei aditive).
|
||||
- [ ] `POST /v1/prezentari` (calea reala) ramane cu comportament identic — `test_api.py` verde;
|
||||
`rar_error` stocat e JSON 3-niveluri pentru needs_data/needs_mapping.
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: canal API — `/valideaza` cu (a) VIN invalid → needs_data + 3 niveluri, (b) cod_op
|
||||
nemapat → needs_mapping + 3 niveluri. Regresia de aur: `POST /v1/prezentari` enqueue neschimbat.
|
||||
|
||||
### US-004: Erorile RAR (400/401) imbracate pe 3 niveluri in worker (`worker`, `rar_client.py`) — DONE
|
||||
**Ca** service-auto **vreau** ca o respingere de la RAR sa fie tradusa in problema + cauza (mesajul RAR
|
||||
exact) + fix, in loc de un JSON brut, **pentru ca** sa inteleg ce a respins RAR fara sa citesc JSON.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/worker/__main__.py`, `app/rar_client.py` (eventual), `tests/test_worker_*.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_worker_rar_errors.py` (nou) —
|
||||
- `test_rar_400_stocheaza_3niveluri` — `RarError(status=400, field_errors=[{field,message}])` →
|
||||
worker stocheaza `rar_error` JSON cu `cod=="RAR_VALIDARE"`, `problema`, `cauza` continand mesajul
|
||||
RAR exact (passthrough), `fix` cu indrumare; pastreaza si `field_errors` originale.
|
||||
- `test_rar_401_creds_3niveluri` — `RarAuthError` → `cod=="RAR_CREDS_INVALIDE"` + 3 niveluri, stare
|
||||
`error` (fara retry, neschimbat).
|
||||
- `test_clasificare_transient_neschimbata` — 5xx/timeout raman transient (retry), comportament identic.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] La RAR 400, `rar_error` stocat = SUPERSET (finding Eng): pastreaza array-ul `field_errors`
|
||||
original `[{field,message}]` (ca `labels.py` actual sa-l randeze per-camp) + ADAUGA invelisul
|
||||
3-niveluri (`cod=RAR_VALIDARE`, `cauza`=mesajul RAR exact passthrough, `fix`=indrumare).
|
||||
- [ ] La RAR 401, `rar_error` = 3-niveluri (`cod=RAR_CREDS_INVALIDE`), stare `error` (fara retry).
|
||||
- [ ] Clasificarea transient vs terminal NESCHIMBATA (5xx/408/429 retry; 4xx terminal); reconcilierea
|
||||
anti-duplicat neatinsa.
|
||||
- [ ] Fara echo de creds in `rar_error` (mesajul RAR nu contine parola; verificat in test).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: worker pe RAR test (daca exista creds) — prezentare cu VIN invalid → RAR 400 →
|
||||
`needs_data` cu `rar_error` 3-niveluri vizibil in dashboard. (Live optional — vezi riscuri.)
|
||||
|
||||
### US-005: Erorile de import imbracate pe 3 niveluri (`import_router.py`) — DONE
|
||||
**Ca** service-auto care incarca un fisier **vreau** ca erorile de upload / mapare coloane / commit sa
|
||||
spuna ce e gresit, de ce si cum repar, **pentru ca** sa pot incarca singur fara suport.
|
||||
|
||||
- **Depinde de**: US-001
|
||||
- **Fisiere**: `app/api/v1/import_router.py`, `tests/test_import_*.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_import_errors.py` (nou) —
|
||||
- `test_fisier_prea_mare_3niveluri` — 413 → detail contine `cod=="IMPORT_FISIER_PREA_MARE"` +
|
||||
problema/cauza (nr randuri vs max)/fix; pastreaza `error`/`message` vechi.
|
||||
- `test_antet_neclar_3niveluri` — HeaderError → `cod=="IMPORT_ANTET_NECLAR"` + 3 niveluri + `found`.
|
||||
- `test_encoding_3niveluri`, `test_fisier_nerecunoscut_3niveluri`, `test_multiple_sheets_3niveluri`.
|
||||
- `test_fara_mapare_coloane_3niveluri` — preview fara mapare → `IMPORT_FARA_MAPARE_COLOANE`.
|
||||
- `test_confirmare_gresita_3niveluri` — commit cu n gresit → `IMPORT_CONFIRMARE_GRESITA` + `n_ok`.
|
||||
- `test_override_ilizibil_3niveluri` — editare cu override corupt → `IMPORT_OVERRIDE_ILIZIBIL`.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Fiecare `HTTPException` de import in scop are `detail` SUPERSET: pastreaza `error`/`message`/
|
||||
campurile contextuale existente (`sheets`/`found`/`n_ok`) si ADAUGA `cod/problema/cauza/fix`.
|
||||
- [ ] Codurile vin din `errors.eroare` (catalog), nu string-uri ad-hoc.
|
||||
- [ ] Toate testele de import existente raman verzi (forma veche `error`/`message` intacta; asertii de
|
||||
subset unde comparau exact — finding Eng).
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: canal API — `POST /v1/import` cu fisier prea mare / antet neclar → detail 3-niveluri.
|
||||
|
||||
### US-006: Componenta UI de eroare pe 3 niveluri + stari submission (`labels.py`, templates core) — DONE
|
||||
**Ca** service-auto **vreau** ca dashboard-ul sa afiseze problema (bold) + cauza + fix (linie de actiune)
|
||||
pentru randurile needs_data / needs_mapping / error si per-camp in preview, **pentru ca** sa stiu ce sa
|
||||
fac fara sa deschid un tichet.
|
||||
|
||||
- **Depinde de**: US-001, US-002, US-003, US-004 (codurile/structura trebuie sa existe)
|
||||
- **Fisiere**: `app/web/labels.py`, `app/web/templates/_eroare.html` (macro nou), `app/web/templates/base.html`
|
||||
(CSS), `app/web/templates/_trimitere_detaliu.html`, `app/web/templates/_status.html`,
|
||||
`app/web/templates/_preview_rand.html`, `tests/test_web_*.py` (~7 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_erori.py` (nou) —
|
||||
- `test_motiv_uman_3niveluri` — `labels.motiv_uman` pe `rar_error` 3-niveluri intoarce
|
||||
problema/cauza/fix (nu doar un string plat); fallback gratios la rar_error vechi/string/corupt.
|
||||
- `test_detaliu_afiseaza_fix` — `/_fragments/...` pe submission needs_data → HTML contine textul `fix`.
|
||||
- `test_preview_rand_per_camp_fix` — preview rand needs_data → fiecare camp invalid arata `fix`-ul.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **Progresiv, dashboard compact pastrat** (finding Design — nu regresa 3.5/3.6): in lista/rand
|
||||
se vede problema (eticheta umana existenta) + fix-ul ca o singura linie de actiune; cauza +
|
||||
mesajul tehnic RAR integral stau in detaliu / `<details>` (nu 3 linii per rand → zid de text).
|
||||
Cele 3 niveluri complete apar in panoul de detaliu si in preview-ul de rand, nu in fiecare rand din lista.
|
||||
- [ ] **Scannabil** (finding Design): nivelele au tratament vizual / etichete care le fac parcurgibile
|
||||
fara citire integrala (ex. "Problema" / "De ce" / "Cum repari", sau ierarhie vizuala clara).
|
||||
- [ ] Macro Jinja `_eroare.html` randeaza consistent; mesajul tehnic RAR integral ramane in `<details>`
|
||||
(pattern existent in `_trimitere_detaliu.html`).
|
||||
- [ ] `labels.py` citeste catalogul / parseaza `rar_error` 3-niveluri; degradeaza gratios pe forma
|
||||
veche (string / `[{field,message}]` / JSON corupt) — fara 500 (lectia 3.6 cu decriptarea).
|
||||
- [ ] Reutilizeaza, NU inlocuieste, pattern-ul bun din `_status.html` (problema + subtext-hint deja
|
||||
~ problema + fix); `_trimitere_detaliu.html`, `_preview_rand.html` folosesc macro-ul.
|
||||
- [ ] CSS in paleta light+dark din 5.3 — fara culori hardcodate; accentul de "fix/actiune" trece AA
|
||||
in AMBELE teme (lectia 5.3: `--ok` pica AA ca text); distinct de rosul de eroare.
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: browser HTMX pe `/` — rand needs_data arata problema+cauza+fix; preview rand
|
||||
invalid arata fix per-camp; dark + light (5.3) ambele lizibile.
|
||||
|
||||
### US-007: 3 niveluri in import / upload / preview UI + rute web (`routes.py`, templates import) — DONE
|
||||
**Ca** service-auto **vreau** ca erorile de la upload, mapare coloane si preview import sa apara cu cele
|
||||
3 niveluri in interfata, **pentru ca** sa rezolv singur problemele de fisier.
|
||||
|
||||
- **Depinde de**: US-006 (macro `_eroare.html`), US-005 (codurile de import)
|
||||
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_upload.html`, `app/web/templates/_mapcoloane.html`,
|
||||
`app/web/templates/_preview_import.html`, `tests/test_web_*.py` (~5 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_import_erori.py` (nou) —
|
||||
- `test_upload_eroare_3niveluri` — upload fisier invalid prin ruta web → fragment contine problema+fix.
|
||||
- `test_mapcoloane_format_json_3niveluri` — format coloane JSON invalid → `COLOANE_FORMAT_JSON` 3 niveluri.
|
||||
- `test_cod_rar_necunoscut_3niveluri` — mapare operatie cu cod RAR inexistent → 3 niveluri + sugestie.
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Caile web de eroare din `routes.py` (upload, mapare coloane, format JSON, cod RAR necunoscut,
|
||||
corectie) trec context 3-niveluri catre template (din catalog), nu string plat.
|
||||
- [ ] `_upload.html`, `_mapcoloane.html`, `_preview_import.html` folosesc macro-ul `_eroare.html`.
|
||||
- [ ] Forma veche (mesaj plat) inca functioneaza unde nu exista cod (fara regresie); toate testele web
|
||||
existente verzi.
|
||||
- [ ] `python3 -m pytest -q` verde.
|
||||
- **Verificare E2E**: browser HTMX — upload fisier prea mare → 3 niveluri; mapare coloane JSON invalid → fix.
|
||||
|
||||
### US-008: Documentare envelope de eroare imbogatit (`api-rar-contract.md`) — DONE
|
||||
**Ca** integrator nou **vreau** sa stiu forma exacta a erorilor (campuri vechi + cele 3 niveluri noi) si
|
||||
lista de coduri, **pentru ca** sa-mi construiesc gestionarea de erori fara reverse-engineering.
|
||||
|
||||
- **Depinde de**: US-001, US-002, US-003, US-005 (codurile finale)
|
||||
- **Fisiere**: `docs/api-rar-contract.md` (~1 fisier)
|
||||
- **Test intai (RED)**: — (doc; verificare manuala). Optional `tests/test_errors.py::test_doc_acopera_codurile`
|
||||
daca e fezabil ieftin (verifica ca fiecare cod din CATALOG apare in doc).
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Sectiune noua in `api-rar-contract.md`: forma erorii (`{field, message, cod, problema, cauza, fix}`),
|
||||
nota de back-compat (campurile vechi raman), tabel cod → problema/fix.
|
||||
- [ ] Mentioneaza ca `/valideaza` si `rar_error` stocat folosesc aceeasi forma.
|
||||
- **Verificare E2E**: review uman al documentului.
|
||||
|
||||
## 4. Catalog de coduri (referinta — definit in US-001)
|
||||
|
||||
| Domeniu | Cod | problema (nivel 1) | unde |
|
||||
|---|---|---|---|
|
||||
| Validare | `VIN_FORMAT` | VIN invalid | US-002 |
|
||||
| Validare | `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | US-002 |
|
||||
| Validare | `DATA_FORMAT` | Data prestatiei in format gresit | US-002 |
|
||||
| Validare | `DATA_PREA_VECHE` | Data prestatiei prea veche | US-002 |
|
||||
| Validare | `DATA_VIITOR` | Data prestatiei in viitor | US-002 |
|
||||
| Validare | `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | US-002 |
|
||||
| Validare | `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | US-002 |
|
||||
| Validare | `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | US-002 |
|
||||
| Validare | `ODOMETRU_INITIAL_ORDINE` | Odometru initial > final | US-002 |
|
||||
| Validare | `PRESTATII_GOALE` | Nicio prestatie | US-002 |
|
||||
| Validare | `B64_INVALID` | Imaginea nu e base64 valid | US-002 |
|
||||
| Mapare | `COD_NEMAPAT` | Lipseste codul RAR al operatiei | US-003 |
|
||||
| Mapare | `AUTO_SEND_OPRIT` | Necesita confirmare manuala | US-003 |
|
||||
| RAR | `RAR_VALIDARE` | RAR a respins prezentarea | US-004 |
|
||||
| RAR | `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | US-004 |
|
||||
| Import | `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | US-005 |
|
||||
| Import | `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | US-005 |
|
||||
| Import | `IMPORT_ENCODING` | Codare de caractere nesuportata | US-005 |
|
||||
| Import | `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | US-005 |
|
||||
| Import | `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | US-005 |
|
||||
| Import | `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | US-005 |
|
||||
| Import | `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | US-005 |
|
||||
| Import | `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | US-005 |
|
||||
| Coloane | `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | US-007 |
|
||||
|
||||
> Lista finala se fixeaza in US-001 (catalog) + drift-test (`test_toate_codurile_in_catalog`). Codurile
|
||||
> nu sunt parte din contractul de back-compat (campuri noi); mesajele RAR exacte raman in `cauza`.
|
||||
|
||||
## 4b. Intrebari deschise (rezolvate inainte de executie)
|
||||
|
||||
- **Latimea scope-ului** — REZOLVAT: focus pe fluxul de declarare (validare continut, RAR 400, import,
|
||||
mapare op→cod); login/signup/CSRF/auth raman plate. [user 2026-06-22]
|
||||
- **Compatibilitate API** — REZOLVAT: aditiv, fara breaking change (campuri vechi pastrate, adaugam
|
||||
`cod/problema/cauza/fix`); documentat in `api-rar-contract.md`. [user 2026-06-22]
|
||||
|
||||
## 5. Riscuri
|
||||
|
||||
- **Drift catalog** (cod folosit dar absent din CATALOG, sau text gol) → `errors.eroare` ridica pe cod
|
||||
necunoscut (US-001) + `test_toate_codurile_in_catalog` (US-002) — drift prins la dezvoltare, nu in prod.
|
||||
- **`fix` generic, fara valoare** (finding CEO) → criteriu de calitate explicit (US-001): fiecare `fix`
|
||||
numeste loc/actiune concreta; verificat la review uman + in design-review-ul UI.
|
||||
- **`rar_error` stocat schimbat rupe `labels.py` intre valuri** (finding Eng) → stocam SUPERSET (old keys
|
||||
intacte) in US-003/US-004; `labels.py` actual ramane functional pana la US-006; zero migrare.
|
||||
- **Teste cu egalitate exacta de dict** (finding Eng) → se trec la asertii de subset (acelasi comportament,
|
||||
chei aditive); contractul de back-compat e pe campurile vechi, nu pe absenta celor noi.
|
||||
- **Breaking change accidental pe API** → aditiv prin constructie + testele existente (`test_api.py`,
|
||||
`test_validare_dryrun.py`, `test_import_*.py`) sunt contractul de back-compat: raman verzi = forma
|
||||
veche `field`/`message`/`error` intacta. AC explicit in fiecare story backend.
|
||||
- **500 la afisare UI pe `rar_error` vechi/corupt** (lectia 3.6: decriptare neprotejata) → `labels.py`
|
||||
degradeaza gratios pe forma veche (string / `[{field,message}]` / JSON corupt), test dedicat (US-006).
|
||||
- **Scurgere de creds prin `cauza`** (mesaj RAR passthrough) → mesajele RAR de validare nu contin parola
|
||||
(field/message pe campuri de prezentare); test no-echo (US-004). Handler-ul 422 din `main.py` deja
|
||||
dropeaza `input`/`ctx`.
|
||||
- **Suprafata mare** → 8 stories pe valuri cu fisiere disjuncte (vezi §6); backend (US-002/004/005)
|
||||
paralel, UI secvential dupa backend, docs paralel.
|
||||
- **Verbozitate UI** (3 niveluri = zgomot) → progresiv: problema + fix vizibile, mesajul tehnic RAR
|
||||
integral ramane in `<details>` (pattern existent in `_trimitere_detaliu.html`).
|
||||
- **Conflict pe fisiere comune** (lectia 5.1: clobber la worktree/merge) → mapping.py atins doar de
|
||||
US-003; routes.py doar de US-007; templates partitionate intre US-006 (detaliu/status/preview_rand) si
|
||||
US-007 (upload/mapcoloane/preview_import); macro `_eroare.html` creat in US-006, consumat in US-007
|
||||
(dependenta de val, nu paralel).
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] backbone catalog (singur — toti depind de el)
|
||||
Val 2: [US-002] [US-004] [US-005] backend paralel, fisiere disjuncte
|
||||
validation.py worker/rar import_router.py
|
||||
Val 3: [US-003] mapping.py + router.py + models.py (depinde US-002)
|
||||
Val 4: [US-006] [US-008] UI core (labels+templates) || docs (api-rar-contract.md) — disjuncte
|
||||
Val 5: [US-007] UI import/web (depinde US-006 pt macro)
|
||||
```
|
||||
|
||||
- **Val 2**: max 2-3 teammates simultan (ROADMAP §5.5). validation.py / worker+rar_client / import_router.py
|
||||
sunt disjuncte → 3 teammates paraleli OK.
|
||||
- **Val 4**: US-006 (templates+labels) si US-008 (doc) ating fisiere disjuncte → paralel.
|
||||
- Dupa fiecare val: lead-ul ruleaza `python3 -m pytest -q` (regresie) si bifeaza stories in PRD.
|
||||
|
||||
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
|
||||
|
||||
> Obligatorii: `/plan-ceo-review` (valoare/scope) + `/plan-eng-review` (fezabilitate/teste).
|
||||
> `/plan-design-review` — DA (atinge UI: US-006, US-007). Rezultatele se aplica IN acest PRD inainte de cod.
|
||||
|
||||
**CEO (valoare/scope) — PASS.** Problema corecta (DX: erorile plate transfera incertitudinea la user →
|
||||
tichete de suport), aliniata cu directia Etapa 5. Scope-ul (declaration flow) e cel mai direct la valoare;
|
||||
login/signup/CSRF taiate corect (decizie user). **Inversiune ("ce-l face sa esueze?"):** un `fix` generic
|
||||
("verifica datele") face dintr-o eroare 3-niveluri doar o eroare plata mai lunga — valoarea traieste sau
|
||||
moare in specificitatea fix-ului. → Aplicat ca criteriu de calitate explicit (US-001 AC + risc): fiecare
|
||||
`fix` numeste loc/actiune concreta. **Deferare constienta:** `POST /v1/prezentari` real intoarce doar
|
||||
`status`, nu `erori` inline — integratorul afla "de ce" printr-un GET sau prin `/valideaza`; a adauga
|
||||
`erori` inline pe ruta reala ar fi aditiv + util, dar e scope creep peste 5.4 → notat ca oportunitate
|
||||
viitoare, nu in scope acum.
|
||||
|
||||
**Eng (fezabilitate/teste) — PASS cu 3 conditii (aplicate in PRD).** Catalogul pur + helper = backbone
|
||||
fezabil, drift prins la dev. **(1) Critic — `rar_error` stocat trebuie SUPERSET** (old keys intacte): altfel
|
||||
`labels.motiv_uman` se strica intre Val 3 (backend schimba forma) si Val 4 (UI o citeste); superset =
|
||||
zero migrare + degradare gratioasa (lectia 3.6). Aplicat in US-003/US-004 AC + risc. **(2) Byte-compat**:
|
||||
validarea pune mesajul existent verbatim ca `cauza`, `message` ramane identic → testele pe `message`
|
||||
raman verzi (US-002). **(3) Teste subset, nu egalitate exacta** de dict (chei aditive) — aplicat in
|
||||
US-002/003/005. `models.py` trebuie sa accepte chei in plus pe `erori`/`nemapate` (verificat in US-003).
|
||||
Worker testabil cu `rar_client` mock-uit (fara live RAR).
|
||||
|
||||
**Design (UI — US-006/US-007) — PASS cu 3 conditii (aplicate in PRD).** **(1) Progresiv, nu regresa
|
||||
dashboard-ul compact din 3.5/3.6**: in lista/rand → problema + fix pe o linie; cauza + tehnic RAR in
|
||||
detaliu/`<details>`; cele 3 niveluri complete doar in panoul de detaliu + preview-ul de rand. **(2)
|
||||
Scannabil**: etichete/ierarhie vizuala ("Problema"/"De ce"/"Cum repari") ca user-ul sa parcurga fara
|
||||
citire integrala. **(3) AA in ambele teme** (lectia 5.3): accentul de "fix/actiune" trece AA light+dark,
|
||||
fara culori hardcodate, distinct de rosul de eroare. Reutilizeaza pattern-ul bun din `_status.html`
|
||||
(problema + subtext = ~ problema + fix), nu il inlocui.
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
|
||||
|
||||
**1. Suita — PASS.** `python3 -m pytest -q` → 628 passed, 234 warnings.
|
||||
|
||||
**2. Acceptance criteria US-001..US-008 — toate PASS.** Verificate direct pe cod + probe live (TestClient + SQLite temp):
|
||||
- US-001: catalog pur, 24 coduri (`MISSING:[]`, `EMPTY:[]`), `eroare('INEXISTENT')`→KeyError, chei `{field,cod,problema,cauza,fix,message}`, `message==cauza`/`==problema` corect.
|
||||
- US-002: **byte-compat confirmat** — `message` VIN identic la octet cu `git show HEAD:app/validation.py` (textul vechi pus ca `cauza`); erori complete pe 6 chei.
|
||||
- US-003: **`rar_error` SUPERSET confirmat** — needs_mapping pastreaza `unmapped`; auto_send pastreaza `auto_send`; needs_data ramane array cu `field`+`message`; `nemapate` din `/valideaza` poarta 3 niveluri; `models.py:113-114` permisiv (`list[dict]`).
|
||||
- US-004: RAR 400→`RAR_VALIDARE` (`field`/`message` pastrate, cauza=mesaj RAR passthrough), RAR 401→`RAR_CREDS_INVALIDE` fara retry fara echo creds; `_is_transient` neschimbat.
|
||||
- US-005: detalii import superset (`error`/`message`/`sheets`/`found`/`n_ok` + 3 niveluri).
|
||||
- US-006: `parse_erori` degradeaza gratios (string plat / `[{field,message}]` fara cod / JSON invalid / None — fara exceptie); detaliu randeaza Problema/De ce/Cum repari + `<details>` tehnic; CSS doar variabile paleta, AA in ambele teme (accent 5.17/5.33, err 4.83/5.06), accent ≠ rosu.
|
||||
- US-007: upload/mapcoloane prin macro; per-camp `camp-fix`.
|
||||
- US-008: contract documentat (forma 6 chei, back-compat, tabel cod→problema/fix complet).
|
||||
- **Calitate `fix` (finding CEO) — PASS**: specifice/actionabile ("talon pozitia E", "tab-ul Mapari", "CSV UTF-8", "maxim 5000 randuri"); niciun fix generic.
|
||||
- **Non-Goal — PASS**: `auth_routes.py`/`csrf.py`/`main.py` NEATINSE (confirmat `git status`).
|
||||
|
||||
**3. E2E canal API — PASS.** `/valideaza`: (a) VIN invalid → `erori[0]` cu `cod/problema/cauza/fix` + `field`/`message` vechi; (b) cod_op nemapat → `nemapate[0]` cu `cod_op_service`/`denumire` + 3 niveluri. `POST /v1/prezentari` real → `200 {status:queued}`.
|
||||
|
||||
**4. E2E canal web (UI, TestClient pe fragmente) — PASS.** Upload invalid → `_upload.html` cu `eroare-3n` + "Cum repari" + fix; submission needs_data → `_trimitere_detaliu.html` cu 3 niveluri + `<details>` tehnic. (Browser Playwright neutilizat — fragmentele TestClient acopera criteriile.)
|
||||
|
||||
**5. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari`→queued + `test_api.py` verde. Flux LIVE RAR (worker→FINALIZATA pe RAR test) NEPROBAT — lipsesc `AUTOPASS_CREDS_KEY`+creds test+`--send` in mediu; NU e FAIL al 5.4 (endpoint-urile/UI noi nu ating trimiterea; worker-ul doar imbogateste `rar_error`).
|
||||
|
||||
**Observatie minora (ne-blocanta), REPARATA la CLOSE:** exemplul JSON din `api-rar-contract.md` avea `message`/`cauza` cu un text VIN usor diferit de cel real emis de `validation.py:72`. Corectat (ambele linii) sa coincida verbatim cu codul.
|
||||
229
docs/prd/prd-5.5-uniformizare-ui.md
Normal file
229
docs/prd/prd-5.5-uniformizare-ui.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# PRD 5.5 — Uniformizare & standardizare UI/UX
|
||||
|
||||
**Stare**: aprobat (2026-06-23)
|
||||
|
||||
> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> **Design vizual (sursa de adevar pentru *cum arata*)**: `docs/design/5.5-uniformizare-ui.md`.
|
||||
> Stare: `draft → aprobat → in-executie → verify-pass → inchis`.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Aducem toate suprafetele dashboard-ului la acelasi vocabular de componente ca tabelul **Trimiteri**
|
||||
(referinta corecta), reasezam navigarea intr-un **meniu de cont** (hamburger) si dam panoului admin
|
||||
actiuni reale de ciclu de viata pe conturi. Tinta: aplicatia arata si se comporta uniform, fara
|
||||
tabele labartate, fara wayfinding redundant, fara scroll orizontal pentru actiuni. Detaliile vizuale
|
||||
si deciziile utilizatorului: `docs/design/5.5-uniformizare-ui.md` (§10).
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **Fara redesign de estetica**: paleta/tipografia/tokenii din `base.html` (5.3) raman NESCHIMBATI la octet.
|
||||
- **Fara atingere a fluxului de trimitere**: worker (masina stari submissions, idempotenta, mapping-rezolvare)
|
||||
NEATINS, cu o singura exceptie controlata — gate-ul `claim_one` pe noua stare de cont (US-004), pastrand
|
||||
echivalenta `active=1 ⇔ status='active'`.
|
||||
- **Fara schimbare a semanticii `auto_send`**: comutatorul Auto/Manual ramane reskin la nivel de macro
|
||||
(`name="auto_send"`, semantica de prezenta). Zero atingere a parserelor `/mapari` si `/_import/...`.
|
||||
- **Fara rute noi de date / fara HTTP nou pe chei API**: lifecycle-ul conturilor e admin-only, sub
|
||||
`require_admin` + CSRF, exact ca rutele admin existente.
|
||||
- **Fara responsive/mobile nou** dincolo de ce ofera deja `.tablewrap` (scroll in card).
|
||||
- **Tabelul Trimiteri ramane neatins** — e referinta, nu tinta.
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
### US-001: Elimina sectiunea "Ajutor" din Acasa
|
||||
**Ca** operator **vreau** o pagina Acasa fara wayfinding redundant **pentru ca** linkurile Mapari/Coduri RAR
|
||||
sunt deja in navigare.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_acasa.html`, `tests/test_web_acasa.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_acasa.py` — `test_acasa_fara_sectiune_ajutor` (randul "Ajutor:" + linkurile
|
||||
inline lipsesc din HTML-ul Acasa)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Blocul `Ajutor: <a>Mapari</a> <a>Coduri RAR</a>` (liniile ~47-55) eliminat din `_acasa.html`.
|
||||
- [ ] Restul Acasa (upload, "Primii pasi", sectiunea Trimiteri) neschimbat.
|
||||
- [ ] `python3 -m pytest tests/test_web_acasa.py -q` verde.
|
||||
- **Verificare E2E**: browser HTMX pe `/` — Acasa nu mai afiseaza randul Ajutor; upload + Trimiteri intacte.
|
||||
|
||||
### US-002: Tabel Nomenclator cu aspectul tabelului Trimiteri
|
||||
**Ca** operator **vreau** ca nomenclatorul sa arate identic cu Trimiteri **pentru ca** consistenta reduce
|
||||
sarcina cognitiva.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_nomenclator.html`, `tests/test_web_nomenclator.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_nomenclator.py` — `test_nomenclator_grila_standard` (`.tablewrap` + `table`
|
||||
+ antet `th` standard + `.pill` pe cod; empty-state `.empty`)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Foloseste aceeasi structura `.tablewrap > table` cu antet `th` standard ca `_submissions.html`.
|
||||
- [ ] Codul prestatie ramane in `.pill`; coloanele aliniate, hover/aspect identice cu Trimiteri.
|
||||
- [ ] Empty-state pastrat (`Nomenclator gol...`), in `.empty`.
|
||||
- [ ] `python3 -m pytest tests/test_web_nomenclator.py -q` verde; AA light+dark (zero literali de culoare).
|
||||
- **Verificare E2E**: browser — Nomenclator si Trimiteri arata din aceeasi familie vizuala in dark si light.
|
||||
|
||||
### US-003: Macro `autosend_toggle` compact (Auto / Manual)
|
||||
**Ca** operator **vreau** un comutator scurt In coada, fara text repetat pe randuri **pentru ca** proza
|
||||
inline ingrasa randurile si impinge actiunile afara din ecran.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_macros.html`, `tests/test_web_macros.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_macros.py` — `test_autosend_compact` (macro-ul randeaza control Auto/Manual,
|
||||
pastreaza `name="auto_send" value="true"` + `form=` + starea `checked`, si NU mai contine propozitiile
|
||||
explicative "La fisierele viitoare..."/"Nebifat = ...")
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `autosend_toggle(form_id, checked)` randeaza un comutator compact etichetat **Auto** / **Manual**
|
||||
(radio sau switch), nowrap, fara propozitii inline.
|
||||
- [ ] Pastreaza EXACT `name="auto_send"`, `value="true"`, semantica de prezenta (bifat→true / absent→false),
|
||||
`form="{{form_id}}"`, `checked` reflecta `checked`.
|
||||
- [ ] Explicatia detaliata NU mai e in macro (se muta in panoul Ajutor, US-005). Tooltip scurt admis pe control.
|
||||
- [ ] `python3 -m pytest tests/test_web_macros.py tests/test_import_e2e.py -q` verde (parserele backend nealterate).
|
||||
- **Verificare E2E**: in fluxul import (mapcoloane) si in Mapari, comutatorul produce acelasi `auto_send`
|
||||
bool ca azi (queued vs needs_review neschimbat).
|
||||
|
||||
### US-004: Model de stare a contului (`accounts.status`) + gate worker
|
||||
**Ca** sistem **vreau** stari de cont distincte (pending/active/blocked/archived/deleted) **pentru ca**
|
||||
adminul are nevoie de blocare/arhivare/stergere, nu doar activ/inactiv.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare defensiva), `app/users.py`, `app/worker/...` (gate `claim_one`),
|
||||
`tests/test_account_status.py`, `tests/test_worker_*.py` (~5 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_account_status.py` — `test_migrare_deriva_status_din_active`,
|
||||
`test_blocked_nu_e_claimuit`, `test_archived_nu_e_claimuit`, `test_dev_id1_protejat`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Coloana `accounts.status` TEXT cu CHECK pe `{pending,active,blocked,archived,deleted}` (stergere = soft,
|
||||
`status='deleted'` + purjare de catre jobul de retentie T16); migrare **defensiva si idempotenta** (pattern
|
||||
`_migrate` ca la `is_admin`), derivata din `active` la prima rulare: `active=1→active`, altfel `pending`.
|
||||
- [ ] Helperi puri in `users.py`: `set_account_status(id, status)`, `delete_account(id)`, cu protectia
|
||||
contului dev `id=1` (ridica/ignora, nu corupe).
|
||||
- [ ] Worker `claim_one` gate-uieste pe `status='active'`, pastrand echivalenta cu `COALESCE(active,1)=1`
|
||||
de azi (conturile blocked/archived NU sunt claimuite).
|
||||
- [ ] `active` ramane consistent (`active=1 ⇔ status='active'`) cat timp coexista, fara regresie pe testele worker.
|
||||
- [ ] `python3 -m pytest -q` verde (suita completa).
|
||||
- **Verificare E2E**: marcheaza un cont `blocked` → submission-urile lui nu pleaca la RAR; `active` → pleaca.
|
||||
|
||||
### US-005: Tabel Mapari standardizat + panou Ajutor
|
||||
**Ca** operator **vreau** tabelele Mapari compacte ca Trimiteri, cu actiunile vizibile fara scroll si ajutor
|
||||
intr-un singur loc **pentru ca** acum sunt labartate si butoanele Salveaza/Sterge ies din ecran.
|
||||
|
||||
- **Depinde de**: US-003
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_mapari.py` — `test_mapari_grila_compacta` (coloane inguste nowrap, actiuni
|
||||
la dreapta), `test_mapari_ajutor_disclosure` (un singur `<details>`/link Ajutor in antet, fara proza pe randuri)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Cele 3 sectiuni (De rezolvat / Mapari salvate / Formate coloane) folosesc grila standard ca Trimiteri;
|
||||
coloana "In coada" foloseste macro-ul compact din US-003.
|
||||
- [ ] Butoanele **Salveaza**/**Sterge** vizibile fara scroll orizontal pe latime de dashboard normala
|
||||
(coloana Actiuni la dreapta, nowrap); sub-text (`N blocate`, `acum: COD`) ca `muted` 12px sub valoare.
|
||||
- [ ] Antetul "De rezolvat" contine un link/`<details>` **Ajutor** cu explicatia maparilor + Auto/Manual,
|
||||
scrisa O SINGURA DATA; proza inline de pe randuri eliminata.
|
||||
- [ ] CSRF, `hx-post`, `hx-target="#mapari-section"`, formularele si re-rezolvarea la edit cod — neschimbate.
|
||||
- [ ] `python3 -m pytest tests/test_web_mapari.py -q` verde; AA light+dark.
|
||||
- **Verificare E2E**: browser — mapezi o operatie (Salveaza vizibil fara scroll), comuti Auto/Manual, deschizi Ajutor;
|
||||
submission blocat se deblocheaza la salvarea codului (comportament neschimbat).
|
||||
|
||||
### US-006: Meniu hamburger in header + context de autentificare
|
||||
**Ca** utilizator **vreau** un meniu de cont in dreapta-sus cu Cont/Integrare/Nomenclator/Panou admin/logout
|
||||
**pentru ca** acestea nu sunt lucru zilnic si aglomereaza tab-bar-ul.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/base.html`, `app/web/routes.py` (helper context partajat),
|
||||
`tests/test_web_header_menu.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_header_menu.py` — `test_meniu_autentificat_are_linkuri_cont`,
|
||||
`test_meniu_admin_doar_pentru_admin`, `test_meniu_neautentificat_fara_logout` (login/signup → fara linkuri de cont)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Iconita `☰` in header (langa toggle tema), `aria-label`, `aria-expanded`, `aria-controls`; dropdown ancorat
|
||||
dreapta-sus; inchidere la click-afara + `Esc` (focus readus pe `☰`). Fara overlay.
|
||||
- [ ] Continut autentificat: Cont, Integrare, Nomenclator, **Panou admin** (doar `is_admin`), separator,
|
||||
**Iesi din cont** (form `POST /logout` cu `csrf_token`).
|
||||
- [ ] `base.html` primeste `is_authenticated`/`is_admin`/`csrf_token` printr-un helper de context partajat (un
|
||||
singur loc); **defensiv**: lipsa cheilor → meniu in stare neautentificata, nu eroare.
|
||||
- [ ] Pe login/signup meniul nu arata linkuri de cont/logout.
|
||||
- [ ] `python3 -m pytest tests/test_web_header_menu.py -q` verde.
|
||||
- **Verificare E2E**: browser — `☰` deschide/inchide (Esc + click-afara), linkurile navigheaza corect, logout iese;
|
||||
pe login meniul nu expune cont.
|
||||
|
||||
### US-007: Tab-bar redus la Acasa · Mapari
|
||||
**Ca** operator **vreau** un tab-bar doar cu suprafetele de lucru zilnic **pentru ca** Cont/Integrare/Nomenclator
|
||||
traiesc acum in meniul de cont.
|
||||
|
||||
- **Depinde de**: US-006
|
||||
- **Fisiere**: `app/web/templates/dashboard.html`, `tests/test_web_dashboard_tabs.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_dashboard_tabs.py` — `test_tabbar_doar_acasa_mapari`,
|
||||
`test_fragmente_mutate_inca_accesibile` (`/_fragments/{cont,integrare,nomenclator}` raman 200 + deep-link `?tab=`)
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `tabs` in `dashboard.html` = doar `acasa`, `mapari`; badge-urile de contoare raman pe Mapari.
|
||||
- [ ] Logout + link admin ad-hoc din coltul dreapta-sus al dashboard-ului eliminate (mutate in meniul US-006).
|
||||
- [ ] Rutele `/_fragments/cont|integrare|nomenclator` + `?tab=` raman valide (accesate din meniu); zero rute moarte,
|
||||
zero 404 pe deep-link existent.
|
||||
- [ ] Navigarea ARIA cu sageti pe tab-bar ramane corecta cu 2 tab-uri.
|
||||
- [ ] `python3 -m pytest tests/test_web_dashboard_tabs.py -q` verde.
|
||||
- **Verificare E2E**: browser — tab-bar arata doar Acasa/Mapari; deschizi Nomenclator/Cont/Integrare din `☰`,
|
||||
deep-link `/?tab=integrare` inca functioneaza.
|
||||
|
||||
### US-008: Rute admin pentru ciclul de viata al conturilor (block/archive/delete + bulk)
|
||||
**Ca** admin **vreau** endpointuri care blocheaza/arhiveaza/sterg conturi, individual si in bulk **pentru ca**
|
||||
panoul are nevoie sa actioneze pe selectie.
|
||||
|
||||
- **Depinde de**: US-004
|
||||
- **Fisiere**: `app/web/routes.py` (rute `/admin/*`), `tests/test_admin_lifecycle.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_admin_lifecycle.py` — `test_block_archive_delete_single`,
|
||||
`test_bulk_pe_lista_account_id`, `test_bulk_sare_contul_dev`, `test_non_admin_403`, `test_csrf_obligatoriu`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Rute `POST /admin/block`, `/admin/archive`, `/admin/delete` (+ pastreaza `activate`) sub `require_admin` + CSRF,
|
||||
cu PRG (redirect inapoi la `/admin`), folosind helperii din US-004.
|
||||
- [ ] Accepta o LISTA de `account_id` (bulk) si o singura tinta (per-rand) prin acelasi handler.
|
||||
- [ ] Contul dev `id=1` e sarit in bulk (nu eroare) si refuzat individual; `delete` cere confirmare la nivel UI
|
||||
(US-009) si purjeaza datele conform retentiei (GDPR/L.142).
|
||||
- [ ] Non-admin → 403; lipsa CSRF → respins.
|
||||
- [ ] `python3 -m pytest tests/test_admin_lifecycle.py -q` verde.
|
||||
- **Verificare E2E**: POST autentificat ca admin pe fiecare verb (single + bulk) muta starea corect; contul dev neatins.
|
||||
|
||||
### US-009: Panou admin — selectie cu bife + bara bulk + actiuni per-rand
|
||||
**Ca** admin **vreau** sa selectez conturi si sa aplic actiuni pe selectie **pentru ca** activarea/blocarea una
|
||||
cate una e lenta.
|
||||
|
||||
- **Depinde de**: US-008
|
||||
- **Fisiere**: `app/web/templates/admin.html`, `tests/test_web_admin.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_admin.py` — `test_admin_coloana_selectie_si_master`,
|
||||
`test_bara_bulk_cu_cele_4_verbe`, `test_actiuni_per_rand`, `test_fara_nota_cont_dev`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Tabel conturi in asteptare (si analog active): coloana checkbox + master "Selecteaza tot"
|
||||
(`aria-label` per rand + master).
|
||||
- [ ] Bara de actiuni bulk (ascunsa pana la selectie) cu **Activeaza / Blocheaza / Arhiveaza / Sterge**;
|
||||
`Sterge` cu `hx-confirm`/dialog; trimite lista de `account_id` la rutele US-008.
|
||||
- [ ] Actiuni per-rand (kebab `...`) cu aceleasi verbe; `Sterge` cu `color:var(--err)` + confirmare.
|
||||
- [ ] Nota "Cont dev implicit (id=1)" **eliminata** din pagina (protectia ramane in cod, US-004/US-008).
|
||||
- [ ] `python3 -m pytest tests/test_web_admin.py -q` verde; AA light+dark; tabel in grila standard.
|
||||
- **Verificare E2E**: browser ca admin — bifezi 2 conturi, bara bulk apare cu numarul selectat, Arhiveaza muta randurile;
|
||||
Sterge cere confirmare; contul dev nu poate fi selectat-distrus.
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Schema `accounts.status` (US-004)** = singura schimbare de date. Mitigare: migrare defensiva idempotenta (pattern
|
||||
`_migrate` deja folosit la `accounts.active`/`users.is_admin`), derivata din `active`, cu echivalenta `active=1 ⇔
|
||||
status='active'` pana cand `active` poate fi retras intr-o livrabila viitoare. Testele worker existente sunt plasa.
|
||||
- **`base.html` partajat (US-006)**: e folosit de login/signup/admin/dashboard. Risc de context lipsa → meniu rupt.
|
||||
Mitigare: helper de context partajat + defaulturi defensive (lipsa → neautentificat), test pe toate cele 4 pagini.
|
||||
- **Coliziune pe fisiere intre stories** (lectia 5.1 clobber): US-006 si US-007 ating ambele zona de navigare
|
||||
(`base.html` vs `dashboard.html`, plus scoaterea logout-ului ad-hoc din `dashboard.html`). Mitigare: US-007 depinde
|
||||
de US-006 si ruleaza secvential (acelasi teammate recomandat), nu in worktree-uri paralele.
|
||||
- **`delete` cont (US-008)**: actiune distructiva ireversibila. Mitigare: confirmare UI obligatorie, contul dev protejat,
|
||||
purjare aliniata la retentia existenta (T16), nu stergere ad-hoc de date conexe fara plan.
|
||||
- **Macro autosend (US-003)**: orice schimbare de `name`/semantica ar rupe tacit clasificarea queued/needs_review.
|
||||
Mitigare: test care asereaza `name="auto_send"` + prezenta, plus `test_import_e2e` ramane verde.
|
||||
|
||||
## 5. Intrebari deschise — REZOLVATE (aprobare utilizator 2026-06-23)
|
||||
|
||||
- **Stergere cont** → **soft delete**: `status='deleted'`, scos imediat din toate listele, date purjate de jobul
|
||||
de retentie existent (T16, GDPR/L.142). NU hard DELETE imediat (auditabil + fereastra de revenire).
|
||||
- **Blocheaza vs Arhiveaza** → `blocked` = suspendare **reversibila**, contul ramane in liste, marcat vizibil,
|
||||
worker nu trimite; `archived` = **scos din listele active**, date pastrate read-only. Etichete confirmate.
|
||||
- **Comutatorul In coada** → **radio etichetat Auto / Manual** (explicit), nu switch on/off.
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] [US-002] [US-003] [US-004] ← fara dependente, fisiere disjuncte → paralel
|
||||
Val 2: [US-005] ← dep US-003 (macro)
|
||||
[US-006] ← fara dep (navigare/header)
|
||||
[US-008] ← dep US-004 (rute admin pe model stare)
|
||||
Val 3: [US-007] ← dep US-006 (acelasi fisier de navigare → secvential, NU worktree paralel)
|
||||
[US-009] ← dep US-008 (UI admin pe rutele de lifecycle)
|
||||
```
|
||||
@@ -102,12 +102,13 @@ def test_acasa_fara_linkuri_ajutor(client):
|
||||
assert 'href="/?tab=coada"' not in r.text
|
||||
|
||||
|
||||
def test_acasa_pastreaza_wayfinding_mapari_coduri(client):
|
||||
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'."""
|
||||
def test_acasa_fara_wayfinding_ajutor(client):
|
||||
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
|
||||
navigarea traieste in tab-bar si in meniul de cont."""
|
||||
r = client.get("/?tab=acasa")
|
||||
html = r.text
|
||||
assert 'href="/?tab=mapari"' in html
|
||||
assert "Coduri RAR" in html
|
||||
assert "Ajutor:" not in html
|
||||
assert "Coduri RAR" not in html
|
||||
|
||||
|
||||
def test_badge_trimiteri_scoped_pe_acasa(client):
|
||||
|
||||
160
tests/test_account_status.py
Normal file
160
tests/test_account_status.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
|
||||
|
||||
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
|
||||
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
|
||||
din listare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_status.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _status(conn, acct_id):
|
||||
return conn.execute("SELECT status, active FROM accounts WHERE id=?", (acct_id,)).fetchone()
|
||||
|
||||
|
||||
def test_create_account_activ_status_active(conn):
|
||||
from app.accounts import create_account
|
||||
acct_id = create_account(conn, "Service X", active=True)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == "active" and row["active"] == 1
|
||||
|
||||
|
||||
def test_create_account_inactiv_status_pending(conn):
|
||||
from app.accounts import create_account
|
||||
acct_id = create_account(conn, "Service Y", active=False)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == "pending" and row["active"] == 0
|
||||
|
||||
|
||||
def test_default_account_id1_active(conn):
|
||||
row = _status(conn, 1)
|
||||
assert row["status"] == "active" and row["active"] == 1
|
||||
|
||||
|
||||
def test_set_status_mentine_invariant_active(conn):
|
||||
from app.accounts import create_account, set_status
|
||||
acct_id = create_account(conn, "Service Z", active=True)
|
||||
for st, exp_active in [("blocked", 0), ("archived", 0), ("active", 1), ("pending", 0)]:
|
||||
set_status(conn, acct_id, st)
|
||||
row = _status(conn, acct_id)
|
||||
assert row["status"] == st and row["active"] == exp_active
|
||||
|
||||
|
||||
def test_set_status_invalid_ridica(conn):
|
||||
from app.accounts import create_account, set_status
|
||||
acct_id = create_account(conn, "Service W", active=True)
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, acct_id, "inexistent")
|
||||
|
||||
|
||||
def test_set_status_cont_inexistent_ridica(conn):
|
||||
from app.accounts import set_status
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, 9999, "blocked")
|
||||
|
||||
|
||||
def test_set_active_mirror_status(conn):
|
||||
from app.accounts import create_account, set_active
|
||||
acct_id = create_account(conn, "Service M", active=True)
|
||||
set_active(conn, acct_id, False)
|
||||
assert _status(conn, acct_id)["status"] == "pending"
|
||||
set_active(conn, acct_id, True)
|
||||
assert _status(conn, acct_id)["status"] == "active"
|
||||
|
||||
|
||||
def test_delete_account_soft(conn):
|
||||
from app.accounts import create_account, delete_account, list_accounts
|
||||
acct_id = create_account(conn, "Service D", active=True)
|
||||
delete_account(conn, acct_id)
|
||||
assert _status(conn, acct_id)["status"] == "deleted"
|
||||
# exclus din listare
|
||||
assert all(a["id"] != acct_id for a in list_accounts(conn))
|
||||
|
||||
|
||||
def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
|
||||
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
|
||||
from app.accounts import create_account, delete_account, list_accounts
|
||||
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
|
||||
conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,))
|
||||
conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,))
|
||||
conn.commit()
|
||||
|
||||
delete_account(conn, acct_id)
|
||||
|
||||
row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?",
|
||||
(acct_id,)).fetchone()
|
||||
assert row["status"] == "deleted"
|
||||
assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere"
|
||||
assert row["cui"] is None, "CUI trebuie eliberat la stergere"
|
||||
key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone()
|
||||
assert key_active["active"] == 0, "cheile API trebuie revocate"
|
||||
# CUI eliberat -> se poate re-inregistra acelasi CUI
|
||||
new_id = create_account(conn, "Service Nou", cui="RO12345", active=True)
|
||||
assert new_id != acct_id
|
||||
assert all(a["id"] != acct_id for a in list_accounts(conn))
|
||||
|
||||
|
||||
def test_dev_id1_protejat_de_status_negativ(conn):
|
||||
from app.accounts import set_status, delete_account
|
||||
for verb in ("blocked", "archived", "deleted"):
|
||||
with pytest.raises(ValueError):
|
||||
set_status(conn, 1, verb)
|
||||
with pytest.raises(ValueError):
|
||||
delete_account(conn, 1)
|
||||
# ramane activ
|
||||
assert _status(conn, 1)["status"] == "active"
|
||||
|
||||
|
||||
def test_migrare_deriva_status_din_active(conn):
|
||||
"""DB veche fara coloana status -> _migrate o adauga si o deriva din active.
|
||||
|
||||
Pornim de la schema reala (fixtura `conn` a rulat init_db), reconstruim tabela accounts
|
||||
FARA coloana status (simuleaza DB pre-5.5), apoi rulam _migrate.
|
||||
"""
|
||||
from app.db import _migrate
|
||||
|
||||
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
|
||||
conn.executescript(
|
||||
"""
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE accounts_legacy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT
|
||||
);
|
||||
INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at)
|
||||
SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts;
|
||||
DROP TABLE accounts;
|
||||
ALTER TABLE accounts_legacy RENAME TO accounts;
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO accounts (name, active) VALUES ('Activ', 1)")
|
||||
conn.execute("INSERT INTO accounts (name, active) VALUES ('Inactiv', 0)")
|
||||
conn.commit()
|
||||
assert "status" not in {r["name"] for r in conn.execute("PRAGMA table_info(accounts)")}
|
||||
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
rows = {r["name"]: r["status"] for r in conn.execute("SELECT name, status FROM accounts")}
|
||||
assert rows["default"] == "active" # id=1
|
||||
assert rows["Activ"] == "active"
|
||||
assert rows["Inactiv"] == "pending"
|
||||
@@ -112,4 +112,4 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
||||
assert ids == sorted(ids)
|
||||
for r in rows:
|
||||
assert "rar_creds_enc" not in r
|
||||
assert set(r.keys()) == {"id", "name", "cui", "active", "created_at"}
|
||||
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}
|
||||
|
||||
137
tests/test_admin_lifecycle.py
Normal file
137
tests/test_admin_lifecycle.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
|
||||
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_lifecycle.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csrf(client, url="/admin"):
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, f"csrf negasit in {url}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client, name, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/signup")
|
||||
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
|
||||
"csrf_token": tok}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email,)).fetchone()
|
||||
return int(row["account_id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_admin(account_id):
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/login")
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _status(account_id):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT status FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return row["status"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _admin_login(client):
|
||||
admin_id = _signup(client, "Admin SA", "admin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "admin@test.ro")
|
||||
return admin_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verb,expected", [
|
||||
("block", "blocked"),
|
||||
("archive", "archived"),
|
||||
("delete", "deleted"),
|
||||
])
|
||||
def test_lifecycle_single(client, verb, expected):
|
||||
target = _signup(client, "Tinta SRL", "tinta@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
resp = client.post(f"/admin/{verb}", data={"account_id": target, "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(target) == expected
|
||||
|
||||
|
||||
def test_bulk_pe_lista_account_id(client):
|
||||
a = _signup(client, "A SRL", "a@test.ro")
|
||||
b = _signup(client, "B SRL", "b@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
resp = client.post("/admin/block", data={"account_id": [a, b], "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(a) == "blocked" and _status(b) == "blocked"
|
||||
|
||||
|
||||
def test_bulk_sare_contul_dev(client):
|
||||
target = _signup(client, "Tinta SRL", "t2@test.ro")
|
||||
_admin_login(client)
|
||||
tok = _csrf(client)
|
||||
# include id=1 (cont de sistem) in selectie -> sarit, fara eroare; tinta procesata
|
||||
resp = client.post("/admin/archive", data={"account_id": [1, target], "csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
assert _status(1) == "active", "contul dev id=1 trebuie sa ramana neatins"
|
||||
assert _status(target) == "archived"
|
||||
|
||||
|
||||
def test_non_admin_403(client):
|
||||
target = _signup(client, "Tinta SRL", "t3@test.ro")
|
||||
_signup(client, "Neadmin SRL", "plain@test.ro")
|
||||
_login(client, "plain@test.ro")
|
||||
# csrf de pe o pagina accesibila non-admin
|
||||
tok = _csrf(client, "/")
|
||||
resp = client.post("/admin/block", data={"account_id": target, "csrf_token": tok})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_csrf_obligatoriu(client):
|
||||
target = _signup(client, "Tinta SRL", "t4@test.ro")
|
||||
_admin_login(client)
|
||||
resp = client.post("/admin/delete", data={"account_id": target}) # fara csrf_token
|
||||
assert resp.status_code != 303
|
||||
assert _status(target) != "deleted"
|
||||
@@ -103,13 +103,13 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
|
||||
# --- markup / copy ---
|
||||
|
||||
def test_comutator_coada_prezent():
|
||||
"""Textul tinteste COADA ("in coada"/"verificare"), NU "trimite"/"Manual" gol."""
|
||||
"""5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact.
|
||||
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
|
||||
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
|
||||
html = _macro_html()
|
||||
assert "in coada" in html, "comutatorul trebuie sa vorbeasca despre coada"
|
||||
assert "verificare" in html, "optiunea de verificare manuala trebuie prezenta"
|
||||
assert "name=\"auto_send\"" in html and 'value="true"' in html
|
||||
# framing periculos interzis (citit global = send-safety):
|
||||
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)"
|
||||
assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
|
||||
assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
|
||||
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
|
||||
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
|
||||
|
||||
@@ -179,8 +179,9 @@ def test_comutator_in_tab_mapari(client):
|
||||
_login(client, "tm@test.com")
|
||||
resp = client.get("/?tab=mapari")
|
||||
assert resp.status_code == 200
|
||||
assert "Pune automat in coada" in resp.text
|
||||
assert "aceasta operatie" in resp.text
|
||||
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
|
||||
assert 'name="auto_send"' in resp.text
|
||||
assert "Manual" in resp.text and "Auto" in resp.text
|
||||
|
||||
|
||||
def test_comutator_in_panou_preview(client):
|
||||
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
|
||||
assert "Pune automat in coada" in r.text
|
||||
assert "aceasta operatie" in r.text
|
||||
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
|
||||
assert 'name="auto_send"' in r.text
|
||||
assert "Manual" in r.text and "Auto" in r.text
|
||||
|
||||
77
tests/test_errors.py
Normal file
77
tests/test_errors.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Teste pentru app/errors.py — catalog central de erori (US-001 / PRD 5.4).
|
||||
|
||||
Urmeaza TDD: testele sunt scrise INAINTE de implementare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.errors import CATALOG, eroare
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_catalog_complet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_catalog_complet():
|
||||
"""Fiecare intrare din CATALOG are 'problema' si 'fix' ne-goale."""
|
||||
assert len(CATALOG) >= 24, "CATALOG trebuie sa contina cel putin 24 de coduri"
|
||||
for cod, entry in CATALOG.items():
|
||||
assert "problema" in entry, f"Lipseste 'problema' pentru codul {cod!r}"
|
||||
assert "fix" in entry, f"Lipseste 'fix' pentru codul {cod!r}"
|
||||
assert isinstance(entry["problema"], str) and entry["problema"].strip(), (
|
||||
f"'problema' goala pentru codul {cod!r}"
|
||||
)
|
||||
assert isinstance(entry["fix"], str) and entry["fix"].strip(), (
|
||||
f"'fix' gol pentru codul {cod!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_eroare_construieste_3niveluri
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eroare_construieste_3niveluri():
|
||||
"""eroare() intoarce dict cu exact cheile asteptate si valorile corecte."""
|
||||
rezultat = eroare("VIN_FORMAT", field="vin", cauza="VIN-ul are 15 caractere")
|
||||
|
||||
chei_asteptate = {"field", "cod", "problema", "cauza", "fix", "message"}
|
||||
assert set(rezultat.keys()) == chei_asteptate, (
|
||||
f"Cheile obtinute: {set(rezultat.keys())} — asteptate: {chei_asteptate}"
|
||||
)
|
||||
|
||||
assert rezultat["cod"] == "VIN_FORMAT"
|
||||
assert rezultat["field"] == "vin"
|
||||
assert rezultat["cauza"] == "VIN-ul are 15 caractere"
|
||||
assert rezultat["problema"] == CATALOG["VIN_FORMAT"]["problema"]
|
||||
assert rezultat["fix"] == CATALOG["VIN_FORMAT"]["fix"]
|
||||
# message == cauza cand cauza este dat
|
||||
assert rezultat["message"] == "VIN-ul are 15 caractere"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_message_back_compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_message_back_compat():
|
||||
"""message == cauza cand cauza e dat; message == problema cand cauza lipseste."""
|
||||
# Cu cauza
|
||||
cu_cauza = eroare("DATA_FORMAT", cauza="data_primita=31/06/2026")
|
||||
assert cu_cauza["message"] == "data_primita=31/06/2026"
|
||||
|
||||
# Fara cauza
|
||||
fara_cauza = eroare("DATA_FORMAT")
|
||||
assert fara_cauza["message"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||
# cauza din dict e None sau egala cu problema
|
||||
assert fara_cauza["cauza"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_cod_necunoscut_ridica
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cod_necunoscut_ridica():
|
||||
"""eroare() pe cod absent din CATALOG ridica KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
eroare("INEXISTENT")
|
||||
335
tests/test_import_errors.py
Normal file
335
tests/test_import_errors.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Teste US-005 (PRD 5.4): erori de import imbracate pe 3 niveluri.
|
||||
|
||||
Verifica ca fiecare HTTPException de import in scop are `detail` superset:
|
||||
- cheile vechi: `error`, `message`, context specific (sheets/found/n_ok)
|
||||
- cheile noi din catalog: `cod`, `problema`, `cauza`, `fix`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixture client #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client FastAPI cu DB temporara izolata per test."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "err.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.crypto import reset_cache
|
||||
reset_cache()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
reset_cache()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
|
||||
_ROW_OK = ["WVWZZZ1KZAW000123", "B999TST", "2026-06-15", "123456", "Revizie"]
|
||||
|
||||
|
||||
def _make_xlsx(rows: list[list], extra_sheets: list[str] | None = None) -> bytes:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
if extra_sheets:
|
||||
for name in extra_sheets:
|
||||
ws2 = wb.create_sheet(name)
|
||||
ws2.append(_HEADER)
|
||||
ws2.append(_ROW_OK)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload(client: TestClient, data: bytes, filename: str = "test.xlsx"):
|
||||
return client.post(
|
||||
"/v1/import",
|
||||
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
def _assert_3niveluri(detail: dict, cod: str) -> None:
|
||||
"""Verifica prezenta cheilor de pe 3 niveluri."""
|
||||
assert detail.get("cod") == cod, f"Asteptat cod={cod!r}, primit {detail.get('cod')!r}"
|
||||
assert "problema" in detail, "Lipseste cheia 'problema'"
|
||||
assert "cauza" in detail, "Lipseste cheia 'cauza'"
|
||||
assert "fix" in detail, "Lipseste cheia 'fix'"
|
||||
assert "error" in detail, "Lipseste cheia veche 'error'"
|
||||
assert "message" in detail, "Lipseste cheia veche 'message'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Fisier prea mare -> IMPORT_FISIER_PREA_MARE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFisierPreaMare3Niveluri:
|
||||
def test_fisier_prea_mare_3niveluri(self, client):
|
||||
"""Upload fisier >5MB -> 413 cu cod IMPORT_FISIER_PREA_MARE + superset."""
|
||||
# 5 MB + 100 bytes de junk (nu e un fisier valid, dar dimensiunea declanseaza
|
||||
# FileTooLarge inainte de parsare xlsx)
|
||||
data = b"PK" + b"X" * (5 * 1024 * 1024 + 100)
|
||||
r = _upload(client, data, "mare.xlsx")
|
||||
# Poate sa returneze 413 sau 422 (depinde daca e prins ca FileTooLarge sau altceva)
|
||||
# Dupa implementare trebuie sa fie 413 pentru FileTooLarge
|
||||
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "file_too_large"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||
|
||||
def test_fisier_prea_mare_peste_5000_randuri(self, client):
|
||||
"""Upload xlsx cu >5000 randuri -> 413 cu cod IMPORT_FISIER_PREA_MARE."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(_HEADER)
|
||||
for i in range(5001):
|
||||
ws.append([f"WVWZZZ1KZAW{i:06d}", f"B{i:04d}TST", "2026-06-15", str(100000 + i), "Revizie"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
r = _upload(client, buf.getvalue(), "mare.xlsx")
|
||||
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "file_too_large"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Antet neclar -> IMPORT_ANTET_NECLAR #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestAntetNeclar3Niveluri:
|
||||
def test_antet_neclar_3niveluri(self, client):
|
||||
"""CSV fara antet recunoscut -> 422 cu IMPORT_ANTET_NECLAR + 'found' pastrat."""
|
||||
# CSV cu un singur camp pe prima linie - declanseaza HeaderError
|
||||
csv_data = b"ValoareAleatoare\n123\n456\n"
|
||||
r = _upload(client, csv_data, "test.csv")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "header_error"
|
||||
assert "found" in detail, "Cheia 'found' trebuie pastrata"
|
||||
_assert_3niveluri(detail, "IMPORT_ANTET_NECLAR")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Encoding nesuportat -> IMPORT_ENCODING #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestEncoding3Niveluri:
|
||||
def test_encoding_3niveluri(self, client):
|
||||
"""CSV in encoding nesuportat (UTF-16 fara BOM detectabil) -> 422 IMPORT_ENCODING."""
|
||||
# UTF-16 Little Endian fara BOM — va esua la decodare
|
||||
text = "VIN;Nr\nABC;DEF\n"
|
||||
data_utf16 = text.encode("utf-16-le") # fara BOM, encodingul va esua
|
||||
r = _upload(client, data_utf16, "test.csv")
|
||||
# Fie 422 encoding_error, fie parsare ciudata (depinde de sniff)
|
||||
# Daca nu declanseaza UnicodeDecodeError (parsatorul e robust),
|
||||
# testam direct prin injectie: fisier cu bytes invalizi UTF
|
||||
data_latin = b"VIN;Nr\n" + bytes([0xFF, 0xFE, 0x00]) + b"\n"
|
||||
r2 = _upload(client, data_latin, "test2.csv")
|
||||
if r2.status_code == 422:
|
||||
detail = r2.json()["detail"]
|
||||
if detail.get("error") == "encoding_error":
|
||||
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||
return
|
||||
# Alternativa: fisier cu bytes complet invalizi pentru toate encodingurile incercate
|
||||
invalid_bytes = b"\xff\xfe" + bytes(range(128, 256)) * 10
|
||||
r3 = _upload(client, invalid_bytes, "test3.csv")
|
||||
if r3.status_code == 422:
|
||||
detail = r3.json()["detail"]
|
||||
if detail.get("error") == "encoding_error":
|
||||
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Fisier nerecunoscut -> IMPORT_FISIER_NERECUNOSCUT #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFisierNerecunoscut3Niveluri:
|
||||
def test_fisier_nerecunoscut_3niveluri(self, client):
|
||||
"""Fisier binar junk -> 422 cu IMPORT_FISIER_NERECUNOSCUT."""
|
||||
r = _upload(client, b"\x00\x01\x02\x03binar", "test.xlsx")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "invalid_file"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_NERECUNOSCUT")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Multiple sheets -> IMPORT_MULTIPLE_SHEETS #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestMultipleSheets3Niveluri:
|
||||
def test_multiple_sheets_3niveluri(self, client):
|
||||
"""Xlsx cu >1 sheet non-gol -> 422 cu IMPORT_MULTIPLE_SHEETS + 'sheets' pastrat."""
|
||||
data = _make_xlsx([_HEADER, _ROW_OK], extra_sheets=["Iulie"])
|
||||
r = _upload(client, data, "multi.xlsx")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "multiple_sheets"
|
||||
assert "sheets" in detail, "Cheia 'sheets' trebuie pastrata"
|
||||
assert "Sheet1" in detail["sheets"] or "Iulie" in detail["sheets"]
|
||||
_assert_3niveluri(detail, "IMPORT_MULTIPLE_SHEETS")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6. Fara mapare coloane -> IMPORT_FARA_MAPARE_COLOANE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFaraMapareColoane3Niveluri:
|
||||
def test_fara_mapare_coloane_3niveluri(self, client):
|
||||
"""Preview fara mapare configurata -> 422 cu IMPORT_FARA_MAPARE_COLOANE."""
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Preview fara a salva maparea de coloane
|
||||
r = client.get(f"/v1/import/{import_id}/preview")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "no_column_mapping"
|
||||
_assert_3niveluri(detail, "IMPORT_FARA_MAPARE_COLOANE")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7. Confirmare gresita -> IMPORT_CONFIRMARE_GRESITA #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestConfirmareGresita3Niveluri:
|
||||
def _seed_op(self) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
|
||||
"VALUES ('OE-1','Verificare')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping "
|
||||
"(account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'Revizie', 'OE-1', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_confirmare_gresita_3niveluri(self, client):
|
||||
"""Commit cu n_confirmat gresit -> 422 cu IMPORT_CONFIRMARE_GRESITA + n_ok pastrat."""
|
||||
self._seed_op()
|
||||
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Salveaza maparea
|
||||
client.post(f"/v1/import/{import_id}/column-mapping", json={
|
||||
"json_mapare": {
|
||||
"VIN": "vin",
|
||||
"Nr inmatriculare": "nr_inmatriculare",
|
||||
"Data prestatie": "data_prestatie",
|
||||
"Odometru final": "odometru_final",
|
||||
"Operatie": "operatie",
|
||||
},
|
||||
"format_data": "YYYY-MM-DD",
|
||||
})
|
||||
|
||||
# Preview pentru a rezolva randurile
|
||||
r_prev = client.get(f"/v1/import/{import_id}/preview")
|
||||
assert r_prev.status_code == 200, r_prev.text
|
||||
|
||||
# Commit cu numarul GRESIT (0 in loc de cel real)
|
||||
r = client.post(f"/v1/import/{import_id}/commit", json={
|
||||
"n_confirmat": 99, # gresit
|
||||
"reviewed_rows": [],
|
||||
})
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "confirmare_gresita"
|
||||
assert "n_ok" in detail, "Cheia 'n_ok' trebuie pastrata"
|
||||
_assert_3niveluri(detail, "IMPORT_CONFIRMARE_GRESITA")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. Override ilizibil -> IMPORT_OVERRIDE_ILIZIBIL #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestOverrideIlizibil3Niveluri:
|
||||
def test_override_ilizibil_forma_dict(self):
|
||||
"""Verifica direct ca detail-ul generat pentru override ilizibil are forma dict corecta.
|
||||
|
||||
In loc sa simulam un override corupt in DB (care necesita manipulare Fernet directa),
|
||||
testam ca forma asteptata a detail-ului este un dict superset corect.
|
||||
Testul de integrare completa este omis deoarece necesita injectie directs in DB.
|
||||
"""
|
||||
from app import errors
|
||||
|
||||
# Simuleaza ce face router-ul la eroarea de override ilizibil
|
||||
msg = "override curent ilizibil; editare anulata"
|
||||
detail = {
|
||||
"error": "override_ilizibil",
|
||||
"message": msg,
|
||||
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=msg),
|
||||
}
|
||||
|
||||
assert detail["error"] == "override_ilizibil"
|
||||
assert detail["message"] == msg
|
||||
assert detail["cod"] == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||
assert "problema" in detail
|
||||
assert "cauza" in detail
|
||||
assert detail["cauza"] == msg
|
||||
assert "fix" in detail
|
||||
|
||||
def test_override_ilizibil_via_api(self, client, monkeypatch):
|
||||
"""Test integrare: override ilizibil returnat ca dict superset (nu string)."""
|
||||
import io as _io
|
||||
|
||||
# Upload + mapare
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Injecteaza direct un override_json corupt in DB
|
||||
import sqlite3
|
||||
from app.config import get_settings
|
||||
db_path = get_settings().db_path
|
||||
conn_raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn_raw.execute(
|
||||
"UPDATE import_rows SET override_json=? WHERE batch_id=? AND row_index=0",
|
||||
("TOKEN_CORUPT_INVALID", import_id),
|
||||
)
|
||||
conn_raw.commit()
|
||||
finally:
|
||||
conn_raw.close()
|
||||
|
||||
# Cerere de editare pe randul cu override corupt
|
||||
r = client.post(f"/v1/import/{import_id}/rand/0/editeaza", json={"vin": "WVWZZZ1KZAW000999"})
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
|
||||
# Dupa US-005: detail trebuie sa fie dict, nu string
|
||||
assert isinstance(detail, dict), f"detail trebuie sa fie dict, primit: {type(detail)}"
|
||||
assert detail.get("error") == "override_ilizibil"
|
||||
assert detail.get("cod") == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||
assert "problema" in detail
|
||||
assert "fix" in detail
|
||||
@@ -161,3 +161,89 @@ def test_op_mapat_declanseaza_regula_odometru(client):
|
||||
def test_item_fara_cod_si_fara_op_e_422(client):
|
||||
r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}]))
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# US-003: 3 niveluri in classify_prezentare (needs_mapping) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_unmapped_are_3niveluri(client):
|
||||
"""cod_op_service necunoscut -> needs_mapping; rar_error are cheie 'unmapped'
|
||||
PASTRATA + campurile COD_NEMAPAT (cod/problema/cauza/fix)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": "OP_NECUNOSCUT", "denumire": "Reparatie necunoscuta"}],
|
||||
}
|
||||
mapping = {}
|
||||
mapping_meta = {}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_mapping"
|
||||
err = json.loads(res["rar_error"])
|
||||
# Cheia originala pastrata
|
||||
assert "unmapped" in err
|
||||
assert len(err["unmapped"]) == 1
|
||||
assert err["unmapped"][0]["cod_op_service"] == "OP_NECUNOSCUT"
|
||||
# 3 niveluri prezente
|
||||
assert err["cod"] == "COD_NEMAPAT"
|
||||
assert err["problema"]
|
||||
assert err["cauza"]
|
||||
assert err["fix"]
|
||||
|
||||
|
||||
def test_auto_send_oprit_3niveluri(client):
|
||||
"""Mapare cu auto_send=0 -> needs_mapping; rar_error are cheie 'auto_send'
|
||||
PASTRATA + campurile AUTO_SEND_OPRIT (cod/problema/cauza/fix)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": "OP_REVIEW", "denumire": "Operatie cu review"}],
|
||||
}
|
||||
mapping = {"OP_REVIEW": "OE-1"}
|
||||
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_mapping"
|
||||
err = json.loads(res["rar_error"])
|
||||
# Cheia originala pastrata
|
||||
assert "auto_send" in err
|
||||
# 3 niveluri prezente
|
||||
assert err["cod"] == "AUTO_SEND_OPRIT"
|
||||
assert err["problema"]
|
||||
assert err["cauza"]
|
||||
assert err["fix"]
|
||||
|
||||
|
||||
def test_needs_data_pass_through(client):
|
||||
"""VIN invalid -> needs_data; rar_error = array cu erori care au cod/problema/fix (US-002)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "VIN_INVALID_XXXXXXXXX", # nu trece regex
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
mapping = {}
|
||||
mapping_meta = {}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_data"
|
||||
erori = json.loads(res["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) >= 1
|
||||
# Fiecare eroare are cele 3 niveluri (pass-through US-002)
|
||||
for e in erori:
|
||||
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||
|
||||
186
tests/test_tema.py
Normal file
186
tests/test_tema.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Teste US-001 + US-002 (PRD 5.3): Light/Dark mode comutator tema.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tema.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 _create_user(email: str = "tema@test.com", password: str = "parolasecreta"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Tema", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta") -> None:
|
||||
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, "csrf_token negasit in /login"
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
|
||||
|
||||
|
||||
# ── US-001: Tema light ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_paleta_light_definita(client):
|
||||
"""HTML de la GET /login contine un selector [data-theme="light"] care redefineste
|
||||
cel putin --bg, --card, --ink, --muted, --line."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert '[data-theme="light"]' in html, 'Lipseste blocul [data-theme="light"] in HTML'
|
||||
|
||||
light_block = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', html, re.DOTALL)
|
||||
assert light_block, 'Nu am gasit blocul CSS [data-theme="light"] { ... }'
|
||||
block = light_block.group(1)
|
||||
for var in ("--bg", "--card", "--ink", "--muted", "--line"):
|
||||
assert var in block, f"Variabila {var} lipseste din blocul [data-theme=\"light\"]"
|
||||
|
||||
|
||||
def test_dark_ramane_default(client):
|
||||
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
|
||||
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
|
||||
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
|
||||
|
||||
|
||||
def test_suprafete_fara_fundal_hardcodat(client):
|
||||
"""<style> NU mai contine literalii hex dark-fix #241a1a, #201c0f, #16241c
|
||||
(banner eroare / banner warn / flash facute theme-aware)."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
|
||||
style_match = re.search(r'<style>(.*?)</style>', resp.text, re.DOTALL)
|
||||
assert style_match, "<style> negasit in HTML"
|
||||
style = style_match.group(1)
|
||||
|
||||
assert "#241a1a" not in style, "Fundalul hardcodat #241a1a (banner eroare) inca in <style>"
|
||||
assert "#201c0f" not in style, "Fundalul hardcodat #201c0f (banner warn) inca in <style>"
|
||||
assert "#16241c" not in style, "Fundalul hardcodat #16241c (flash) inca in <style>"
|
||||
|
||||
|
||||
# ── US-002: Comutator tema + anti-FOUC ────────────────────────────────────────
|
||||
|
||||
def test_script_antifouc_in_head_inainte_de_style(client):
|
||||
"""<head> contine un <script> care citeste localStorage (cheia 'theme') si seteaza
|
||||
data-theme pe document.documentElement, pozitionat INAINTE de <style>."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
|
||||
assert head_match, "<head> negasit in HTML"
|
||||
head = head_match.group(1)
|
||||
|
||||
style_pos = head.find('<style>')
|
||||
assert style_pos >= 0, "<style> negasit in <head>"
|
||||
|
||||
head_before_style = head[:style_pos]
|
||||
assert 'localStorage' in head_before_style, \
|
||||
"Scriptul anti-FOUC (cu localStorage) trebuie sa fie in <head> INAINTE de <style>"
|
||||
assert 'theme' in head_before_style, \
|
||||
"Scriptul anti-FOUC trebuie sa citeasca cheia 'theme' din localStorage"
|
||||
assert ('data-theme' in head_before_style or 'dataset.theme' in head_before_style), \
|
||||
"Scriptul anti-FOUC trebuie sa seteze data-theme pe documentElement"
|
||||
|
||||
|
||||
def test_buton_toggle_in_header_cu_eticheta(client):
|
||||
"""<header> contine un <button> de comutare cu aria-label descriptiv (contine 'tema')."""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
header_match = re.search(r'<header>(.*?)</header>', html, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit in HTML"
|
||||
header = header_match.group(1)
|
||||
|
||||
labels = re.findall(r'<button[^>]*aria-label=["\']([^"\']+)["\']', header, re.IGNORECASE)
|
||||
assert labels, "<button> cu aria-label negasit in <header>"
|
||||
assert any('tema' in lbl.lower() for lbl in labels), \
|
||||
f"Niciun <button> in <header> cu aria-label care contine 'tema'. Gasit: {labels}"
|
||||
|
||||
|
||||
def test_toggle_pe_login_si_dashboard(client):
|
||||
"""Butonul toggle apare atat pe /login (neautentificat) cat si pe dashboard (autentificat)."""
|
||||
# Pe /login
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit pe /login"
|
||||
assert re.search(
|
||||
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
|
||||
header_match.group(1),
|
||||
re.IGNORECASE,
|
||||
), "Butonul toggle lipseste pe /login"
|
||||
|
||||
# Pe dashboard (autentificat)
|
||||
_create_user("tema_dash@test.com", "parolasecreta")
|
||||
_login(client, "tema_dash@test.com", "parolasecreta")
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
|
||||
assert header_match, "<header> negasit pe dashboard"
|
||||
assert re.search(
|
||||
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
|
||||
header_match.group(1),
|
||||
re.IGNORECASE,
|
||||
), "Butonul toggle lipseste pe dashboard"
|
||||
|
||||
|
||||
# ── US-003: Fragmente HTMX fara fundal hardcodat ──────────────────────────────
|
||||
|
||||
def test_fragmente_fara_fundal_hardcodat():
|
||||
"""Niciun fisier _*.html din app/web/templates/ nu contine literalii hex dark-fix
|
||||
#241a1a, #201c0f, #16241c (suprafete banner eroare / warn / flash)."""
|
||||
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
|
||||
fragmente = sorted(templates_dir.glob("_*.html"))
|
||||
assert fragmente, f"Nu am gasit fragmente _*.html in {templates_dir}"
|
||||
|
||||
vinovate = []
|
||||
for f in fragmente:
|
||||
continut = f.read_text(encoding="utf-8")
|
||||
for literal in ("#241a1a", "#201c0f", "#16241c"):
|
||||
if literal in continut:
|
||||
vinovate.append(f"{f.name}: {literal}")
|
||||
|
||||
assert not vinovate, (
|
||||
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
|
||||
+ "\n".join(vinovate)
|
||||
)
|
||||
200
tests/test_validare_dryrun.py
Normal file
200
tests/test_validare_dryrun.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Teste TDD pentru POST /v1/prezentari/valideaza (dry-run, PRD 5.2 US-001)."""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# --- helpere ---
|
||||
|
||||
def _prez(**over):
|
||||
"""Prezentare valida implicita."""
|
||||
p = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
p.update(over)
|
||||
return p
|
||||
|
||||
|
||||
def _body_v(prezentari=None, **over):
|
||||
"""Body pentru /valideaza — rar_credentials optional."""
|
||||
if prezentari is None:
|
||||
prezentari = [_prez(**over)]
|
||||
return {"prezentari": prezentari}
|
||||
|
||||
|
||||
# --- teste ---
|
||||
|
||||
def test_payload_valid_returneaza_queued(client):
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v())
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["valid"] is True
|
||||
assert res["status_estimat"] == "queued"
|
||||
assert res["erori"] == []
|
||||
assert res["index"] == 0
|
||||
|
||||
|
||||
def test_vin_invalid_returneaza_needs_data(client):
|
||||
# VIN cu O/I/Q interzisi
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_data"
|
||||
assert res["valid"] is False
|
||||
fields = [e["field"] for e in res["erori"]]
|
||||
assert "vin" in fields
|
||||
|
||||
|
||||
def test_data_viitoare_needs_data(client):
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v(data_prestatie="2099-01-01"))
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_data"
|
||||
assert res["valid"] is False
|
||||
fields = [e["field"] for e in res["erori"]]
|
||||
assert "data_prestatie" in fields
|
||||
|
||||
|
||||
def test_cod_op_nemapat_returneaza_needs_mapping(client):
|
||||
prez = _prez()
|
||||
prez["prestatii"] = [{"cod_op_service": "REP_MOTOR_NECUNOSCUT", "denumire": "Reparatie motor"}]
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_mapping"
|
||||
assert res["valid"] is False
|
||||
assert len(res["nemapate"]) == 1
|
||||
assert res["nemapate"][0]["cod_op_service"] == "REP_MOTOR_NECUNOSCUT"
|
||||
|
||||
|
||||
def test_mapare_existenta_rezolva_codul(client):
|
||||
# Salveaza mapare op->cod
|
||||
r_map = client.post("/v1/mapari", json={
|
||||
"cod_op_service": "REP_MOTOR",
|
||||
"cod_prestatie": "OE-1",
|
||||
"auto_send": True,
|
||||
})
|
||||
assert r_map.status_code == 200
|
||||
|
||||
prez = _prez()
|
||||
prez["prestatii"] = [{"cod_op_service": "REP_MOTOR", "denumire": "Reparatie motor"}]
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "queued"
|
||||
assert res["valid"] is True
|
||||
assert len(res["prestatii_rezolvate"]) == 1
|
||||
assert res["prestatii_rezolvate"][0]["cod_prestatie"] == "OE-1"
|
||||
|
||||
|
||||
def test_fara_creds_merge(client):
|
||||
# rar_credentials absent -> 200 (optional in schema)
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v())
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_nu_scrie_in_coada(client):
|
||||
# Verifica zero efecte secundare: COUNT(*) neschimbat
|
||||
r_before = client.get("/v1/prezentari")
|
||||
assert r_before.status_code == 200
|
||||
nr_before = len(r_before.json()["submissions"])
|
||||
|
||||
client.post("/v1/prezentari/valideaza", json=_body_v())
|
||||
|
||||
r_after = client.get("/v1/prezentari")
|
||||
assert r_after.status_code == 200
|
||||
nr_after = len(r_after.json()["submissions"])
|
||||
|
||||
assert nr_after == nr_before
|
||||
|
||||
|
||||
def test_multi_prezentari_rezultate_per_index(client):
|
||||
prezentari = [
|
||||
_prez(), # valid -> queued
|
||||
_prez(vin="WVWZZZ1OZIQ45678"), # VIN invalid -> needs_data
|
||||
]
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": prezentari})
|
||||
assert r.status_code == 200
|
||||
results = r.json()["results"]
|
||||
assert len(results) == 2
|
||||
# index corect per pozitie
|
||||
assert results[0]["index"] == 0
|
||||
assert results[1]["index"] == 1
|
||||
# statusuri diferite
|
||||
assert results[0]["status_estimat"] == "queued"
|
||||
assert results[1]["status_estimat"] == "needs_data"
|
||||
|
||||
|
||||
def test_shape_invalid_422(client):
|
||||
# PrestatieItem fara cod_prestatie si fara cod_op_service -> 422 de shape Pydantic
|
||||
prez = _prez()
|
||||
prez["prestatii"] = [{"denumire": "ceva"}] # lipseste cod_prestatie si cod_op_service
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||
assert r.status_code == 422
|
||||
# Handlerul global dropeaza input/ctx — fara echo parola (desi creds lipseste, testam structura)
|
||||
body = r.json()
|
||||
for err in body.get("detail", []):
|
||||
assert "input" not in err
|
||||
assert "ctx" not in err
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# US-003: 3 niveluri in raspunsul /valideaza #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_erori_au_3niveluri(client):
|
||||
"""/valideaza cu VIN invalid -> erori[i] au cod/problema/cauza/fix (pass-through US-002)."""
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_data"
|
||||
erori = res["erori"]
|
||||
assert len(erori) >= 1
|
||||
for e in erori:
|
||||
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||
assert "cauza" in e, f"lipseste 'cauza' in {e}"
|
||||
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||
|
||||
|
||||
def test_nemapate_au_3niveluri(client):
|
||||
"""/valideaza cu cod_op nemapat -> nemapate[i] au cod_op_service+denumire PASTRATE
|
||||
+ cod==COD_NEMAPAT + cele 3 niveluri."""
|
||||
prez = _prez()
|
||||
prez["prestatii"] = [{"cod_op_service": "OP_TEST_NEMAPAT", "denumire": "Operatie test"}]
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_mapping"
|
||||
nemapate = res["nemapate"]
|
||||
assert len(nemapate) == 1
|
||||
n = nemapate[0]
|
||||
# Campuri originale pastrate
|
||||
assert n["cod_op_service"] == "OP_TEST_NEMAPAT"
|
||||
assert n["denumire"] == "Operatie test"
|
||||
# 3 niveluri adaugate
|
||||
assert n["cod"] == "COD_NEMAPAT"
|
||||
assert n["problema"]
|
||||
assert n["cauza"]
|
||||
assert n["fix"]
|
||||
@@ -137,3 +137,114 @@ def test_b64image_valid_ok():
|
||||
def test_erori_multiple_cumulate():
|
||||
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
|
||||
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-002: 3 niveluri — coduri stabile, forma aditiva, back-compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app import errors as err_mod # noqa: E402
|
||||
|
||||
|
||||
def test_vin_invalid_are_3niveluri():
|
||||
"""VIN cu O/I/Q => eroarea are cod, problema, fix, field, message neschimbat."""
|
||||
errs = validate_prezentare(_base(vin="WVWZZZ1OZAW000123"))
|
||||
vin_errs = [e for e in errs if e.get("field") == "vin"]
|
||||
assert vin_errs, "Trebuie cel putin o eroare cu field==vin"
|
||||
e = vin_errs[0]
|
||||
assert e["cod"] == "VIN_FORMAT"
|
||||
assert e["problema"], "problema trebuie sa fie ne-goala"
|
||||
assert e["fix"], "fix trebuie sa fie ne-gol"
|
||||
assert e["field"] == "vin"
|
||||
# message trebuie sa fie mesajul existent (back-compat)
|
||||
assert "17" in e["message"] or "O, I, Q" in e["message"]
|
||||
|
||||
|
||||
def test_data_prea_veche_cod():
|
||||
errs = validate_prezentare(_base(data_prestatie="2024-11-30"))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_PREA_VECHE"
|
||||
|
||||
|
||||
def test_data_viitor_cod():
|
||||
from datetime import timedelta
|
||||
maine = (today_bucuresti() + timedelta(days=1)).isoformat()
|
||||
errs = validate_prezentare(_base(data_prestatie=maine))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_VIITOR"
|
||||
|
||||
|
||||
def test_data_format_cod():
|
||||
errs = validate_prezentare(_base(data_prestatie="15-06-2026"))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_FORMAT"
|
||||
|
||||
|
||||
def test_odometru_initial_lipsa_cod():
|
||||
errs = validate_prezentare(_base(prestatii=[{"cod_prestatie": "R-ODO"}]))
|
||||
oi = [e for e in errs if e.get("field") == "odometru_initial"]
|
||||
assert oi
|
||||
assert oi[0]["cod"] == "ODOMETRU_INITIAL_LIPSA"
|
||||
|
||||
|
||||
def test_odometru_ordine_cod():
|
||||
c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000")
|
||||
errs = validate_prezentare(c)
|
||||
oi = [e for e in errs if e.get("field") == "odometru_initial" and e.get("cod") == "ODOMETRU_INITIAL_ORDINE"]
|
||||
assert oi, "Trebuie eroare cu cod ODOMETRU_INITIAL_ORDINE"
|
||||
|
||||
|
||||
def test_prestatii_goale_cod():
|
||||
errs = validate_prezentare(_base(prestatii=[]))
|
||||
pr = [e for e in errs if e.get("field") == "prestatii"]
|
||||
assert pr
|
||||
assert pr[0]["cod"] == "PRESTATII_GOALE"
|
||||
|
||||
|
||||
def test_b64_invalid_cod():
|
||||
errs = validate_prezentare(_base(b64_image="@@@not-base64@@@"))
|
||||
b = [e for e in errs if e.get("field") == "b64_image"]
|
||||
assert b
|
||||
assert b[0]["cod"] == "B64_INVALID"
|
||||
|
||||
|
||||
def test_back_compat_field_message():
|
||||
"""Fiecare eroare are inca field + message (forma veche)."""
|
||||
errs = validate_prezentare(_base(
|
||||
vin="BAD",
|
||||
nr_inmatriculare="X-Y",
|
||||
data_prestatie="2024-01-01",
|
||||
odometru_final="abc",
|
||||
prestatii=[],
|
||||
))
|
||||
for e in errs:
|
||||
assert "field" in e, f"Lipseste 'field' in {e}"
|
||||
assert "message" in e, f"Lipseste 'message' in {e}"
|
||||
assert e["message"], f"'message' gol in {e}"
|
||||
|
||||
|
||||
def test_toate_codurile_in_catalog():
|
||||
"""Fiecare cod emis de validate_prezentare exista in errors.CATALOG."""
|
||||
from datetime import timedelta
|
||||
|
||||
cazuri = [
|
||||
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
|
||||
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT
|
||||
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
|
||||
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
|
||||
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
|
||||
_base(odometru_final="abc"), # ODOMETRU_FINAL_FORMAT
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}]), # ODOMETRU_INITIAL_LIPSA
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="abc"), # ODOMETRU_INITIAL_FORMAT
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000"), # ODOMETRU_INITIAL_ORDINE
|
||||
_base(prestatii=[]), # PRESTATII_GOALE
|
||||
_base(b64_image="@@@not-base64@@@"), # B64_INVALID
|
||||
]
|
||||
for caz in cazuri:
|
||||
errs = validate_prezentare(caz)
|
||||
for e in errs:
|
||||
if "cod" in e:
|
||||
assert e["cod"] in err_mod.CATALOG, f"Cod {e['cod']!r} absent din CATALOG"
|
||||
|
||||
128
tests/test_web_admin.py
Normal file
128
tests/test_web_admin.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Teste US-009 (PRD 5.5): panou admin UI — selectie cu bife + master, bara de actiuni bulk
|
||||
(Activeaza/Blocheaza/Arhiveaza/Sterge), actiuni per-rand, fara nota 'cont dev implicit',
|
||||
grila standard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_web_admin.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csrf(client, url):
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client, name, email, password="parola_test_001"):
|
||||
tok = _csrf(client, "/signup")
|
||||
client.post("/signup", data={"name": name, "email": email, "parola": password,
|
||||
"csrf_token": tok}, follow_redirects=True)
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email,)).fetchone()
|
||||
return int(row["account_id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _admin_login(client):
|
||||
target = _signup(client, "Pending SRL", "pending@test.ro") # cont in asteptare
|
||||
admin_id = _signup(client, "Admin SA", "admin@test.ro")
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, admin_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
tok = _csrf(client, "/login")
|
||||
resp = client.post("/login", data={"email": "admin@test.ro", "parola": "parola_test_001",
|
||||
"csrf_token": tok})
|
||||
assert resp.status_code == 303
|
||||
return target
|
||||
|
||||
|
||||
def test_admin_coloana_selectie_si_master(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
# checkbox de selectie pe rand + master
|
||||
assert 'name="account_id"' in html
|
||||
assert 'type="checkbox"' in html
|
||||
assert "Selecteaza tot" in html or 'data-master' in html
|
||||
|
||||
|
||||
def test_bara_bulk_cu_cele_4_verbe(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert 'formaction="/admin/activate"' in html
|
||||
assert 'formaction="/admin/block"' in html
|
||||
assert 'formaction="/admin/archive"' in html
|
||||
assert 'formaction="/admin/delete"' in html
|
||||
# bara e ascunsa initial (hidden), fara display inline care ar invinge [hidden]
|
||||
assert re.search(r'class="bulk-bar"\s+hidden', html) or re.search(r'hidden[^>]*class="bulk-bar"', html)
|
||||
assert "bulk-bar" in html and ".bulk-bar[hidden]" in html # CSS care face hidden eficient
|
||||
|
||||
|
||||
def test_actiuni_per_rand(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
# forme per-rand catre rutele de lifecycle (kebab)
|
||||
assert 'action="/admin/block"' in html
|
||||
assert 'action="/admin/archive"' in html
|
||||
assert 'action="/admin/delete"' in html
|
||||
|
||||
|
||||
def test_fara_nota_cont_dev(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert "cont dev implicit" not in html.lower()
|
||||
assert "Cont dev implicit" not in html
|
||||
|
||||
|
||||
def test_grila_standard(client):
|
||||
_admin_login(client)
|
||||
html = client.get("/admin").text
|
||||
assert "tablewrap" in html
|
||||
|
||||
|
||||
def test_cont_arhivat_in_blocul_suspendate(client):
|
||||
"""Gruparea pe STARE: un cont arhivat apare in blocul blocate/arhivate, nu in 'in asteptare'."""
|
||||
target = _admin_login(client) # cont pending seedat
|
||||
from app.db import get_connection
|
||||
from app.accounts import set_status
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_status(conn, target, "archived")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
html = client.get("/admin").text
|
||||
# contul arhivat ajunge in blocul suspendate (1 cont), nu in "in asteptare"
|
||||
assert re.search(r"Conturi blocate / arhivate \(1\)", html)
|
||||
assert ">archived<" in html
|
||||
285
tests/test_web_erori.py
Normal file
285
tests/test_web_erori.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Teste US-006 (PRD 5.4): Componenta UI de eroare pe 3 niveluri.
|
||||
|
||||
Pasul A (RED): teste scrise inainte de implementare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste pure pentru parse_erori (fara HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import parse_erori # noqa: E402
|
||||
|
||||
|
||||
def test_parse_erori_array_3niveluri():
|
||||
"""Array imbogatit cu cod/problema/cauza/fix -> lista cu toate 3 nivelurile."""
|
||||
rar_error = json.dumps([
|
||||
{
|
||||
"field": "vin",
|
||||
"cod": "VIN_FORMAT",
|
||||
"problema": "VIN invalid",
|
||||
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
"fix": "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule.",
|
||||
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
}
|
||||
])
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "VIN invalid"
|
||||
assert "17" in e["cauza"]
|
||||
assert e["fix"] # non-gol
|
||||
assert e.get("field") == "vin"
|
||||
|
||||
|
||||
def test_parse_erori_unmapped():
|
||||
"""Dict unmapped 3-niveluri (cod=COD_NEMAPAT) -> 1 element corect."""
|
||||
rar_error = json.dumps({
|
||||
"cod": "COD_NEMAPAT",
|
||||
"problema": "Lipseste codul RAR al operatiei",
|
||||
"cauza": "Codul OP-99 nu are mapare RAR.",
|
||||
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari.",
|
||||
"unmapped": [{"cod_op_service": "OP-99", "denumire": "Operatie test"}],
|
||||
})
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "Lipseste codul RAR al operatiei"
|
||||
assert e["fix"]
|
||||
|
||||
|
||||
def test_parse_erori_creds():
|
||||
"""Dict cu cod=RAR_CREDS_INVALIDE -> 1 element corect."""
|
||||
rar_error = json.dumps({
|
||||
"cod": "RAR_CREDS_INVALIDE",
|
||||
"problema": "Credentiale RAR invalide",
|
||||
"cauza": "Autentificarea la RAR a esuat (401).",
|
||||
"fix": "Verifica email-ul si parola contului RAR in tab-ul Cont.",
|
||||
})
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "Credentiale RAR invalide"
|
||||
assert e["fix"]
|
||||
|
||||
|
||||
def test_parse_erori_forma_veche_si_corupt():
|
||||
"""Forma veche [{ field, message }], string plain, None, invalid -> degradeaza fara exceptie."""
|
||||
# Forma veche: lista cu field+message dar fara cod
|
||||
vechi = json.dumps([{"field": "vin", "message": "VIN invalid"}])
|
||||
r = parse_erori(vechi)
|
||||
assert isinstance(r, list)
|
||||
assert len(r) >= 1
|
||||
# Nu arunca pentru niciun element
|
||||
for e in r:
|
||||
assert "problema" in e
|
||||
|
||||
# String plain
|
||||
r2 = parse_erori("Eroare generica de la RAR")
|
||||
assert isinstance(r2, list)
|
||||
assert len(r2) >= 1
|
||||
assert r2[0]["problema"]
|
||||
|
||||
# None
|
||||
r3 = parse_erori(None)
|
||||
assert isinstance(r3, list)
|
||||
assert r3 == []
|
||||
|
||||
# JSON corupt
|
||||
r4 = parse_erori("{invalid json[[[")
|
||||
assert isinstance(r4, list)
|
||||
assert len(r4) >= 1
|
||||
# Nu arunca
|
||||
|
||||
# Dict fara cod (forma veche dict)
|
||||
r5 = parse_erori(json.dumps({"auto_send": "cod-abc", "motiv": "auto_send oprit"}))
|
||||
assert isinstance(r5, list)
|
||||
assert len(r5) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _seed_mapping(conn=None):
|
||||
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1)."""
|
||||
from app.db import get_connection
|
||||
c = conn or get_connection()
|
||||
try:
|
||||
c.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
|
||||
)
|
||||
c.commit()
|
||||
finally:
|
||||
if conn is None:
|
||||
c.close()
|
||||
|
||||
|
||||
def _creeaza_submission_needs_data(client, fix_text=None):
|
||||
"""Creeaza un submission in starea needs_data si returneaza id-ul."""
|
||||
import json as _json
|
||||
from app.db import get_connection
|
||||
|
||||
_seed_mapping()
|
||||
|
||||
# Insereaza direct un submission needs_data cu rar_error 3-niveluri
|
||||
fix_folosit = fix_text or "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule."
|
||||
rar_error_3n = _json.dumps([{
|
||||
"field": "vin",
|
||||
"cod": "VIN_FORMAT",
|
||||
"problema": "VIN invalid",
|
||||
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
"fix": fix_folosit,
|
||||
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
}])
|
||||
|
||||
payload = _json.dumps({
|
||||
"vin": "VIN16CARACT000000",
|
||||
"nr_inmatriculare": "B001TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": 145000,
|
||||
"cod_prestatie": "R-FRANE",
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE"}],
|
||||
})
|
||||
idem_key = f"test-erori-{fix_folosit[:20]}"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO submissions
|
||||
(account_id, idempotency_key, payload_json, status, rar_error, retry_count)
|
||||
VALUES (1, ?, ?, 'needs_data', ?, 0)""",
|
||||
(idem_key, payload, rar_error_3n)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_detaliu_afiseaza_fix(client):
|
||||
"""Fragmentul de detaliu al unui submission needs_data contine textul fix-ului."""
|
||||
sub_id = _creeaza_submission_needs_data(client, "Verifica VIN-ul pe talon (pozitia E)")
|
||||
resp = client.get(f"/_fragments/trimitere/{sub_id}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Fix-ul trebuie sa apara in HTML
|
||||
assert "pozitia E" in html, (
|
||||
f"HTML-ul detaliu nu contine fix-ul ('pozitia E'). Primii 2000 chars:\n{html[:2000]}"
|
||||
)
|
||||
|
||||
|
||||
def _import_preview_cu_vin_invalid(client):
|
||||
"""Efectueaza un import cu un rand cu VIN invalid si returneaza HTML-ul preview."""
|
||||
_seed_mapping()
|
||||
rows = [
|
||||
# rand cu VIN de 16 caractere (invalid, trebuie 17) -> needs_data
|
||||
{"VIN": "VIN16CARACT00000", "Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "145000", "Operatie": "OP-1"},
|
||||
]
|
||||
data = _csv_bytes(rows)
|
||||
|
||||
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
|
||||
assert r.status_code == 200
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||
assert m, f"Nu am gasit import_id. Raspuns: {r.text[:1000]}"
|
||||
import_id = int(m.group(1))
|
||||
|
||||
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
return r.text
|
||||
|
||||
|
||||
def test_preview_rand_per_camp_fix(client):
|
||||
"""Preview rand needs_data -> HTML contine fix-ul per camp (VIN)."""
|
||||
html = _import_preview_cu_vin_invalid(client)
|
||||
# Randul trebuie sa fie needs_data
|
||||
assert "needs_data" in html or "needs_review" in html or "VIN" in html.upper(), (
|
||||
f"HTML-ul preview nu contine starea asteptata. Primii 2000 chars:\n{html[:2000]}"
|
||||
)
|
||||
# Fix-ul pentru VIN trebuie sa apara in preview
|
||||
assert "talon" in html.lower() or "majuscule" in html.lower() or "pozitia" in html.lower(), (
|
||||
f"HTML-ul preview nu contine fix-ul VIN. Primii 3000 chars:\n{html[:3000]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste noi (BUG 1 + BUG 2) — adaugate TDD-style (RED inainte de fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import motiv_uman # noqa: E402
|
||||
from app.errors import eroare # noqa: E402
|
||||
|
||||
|
||||
def test_motiv_uman_creds_3niveluri():
|
||||
"""BUG 1: motiv_uman pe dict 3-niveluri (RAR_CREDS_INVALIDE) -> problema, NU text garbled."""
|
||||
rar_err = json.dumps(eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"))
|
||||
rezultat = motiv_uman("error", rar_err)
|
||||
# Nu trebuie sa contina "field:" / "cod:" (text garbled)
|
||||
assert "field:" not in rezultat, f"Text garbled cu 'field:': {rezultat!r}"
|
||||
assert "cod:" not in rezultat, f"Text garbled cu 'cod:': {rezultat!r}"
|
||||
# Trebuie sa returneze textul 'problema' (primul nivel)
|
||||
data = json.loads(rar_err)
|
||||
problema = data.get("problema") or ""
|
||||
assert problema, "eroare() nu a returnat 'problema' — verifica error_codes.py"
|
||||
assert rezultat == problema[:200], (
|
||||
f"Asteptat {problema[:200]!r}, obtinut {rezultat!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_motiv_uman_unmapped_neschimbat():
|
||||
"""Ramura unmapped inca functioneaza dupa adaugarea ramurii 3-niveluri."""
|
||||
rar_err = json.dumps({"unmapped": [{"cod_op_service": "OP-99", "denumire": "Test"}]})
|
||||
rezultat = motiv_uman("needs_mapping", rar_err)
|
||||
assert rezultat.startswith("Cod RAR lipsa pentru:"), (
|
||||
f"Ramura unmapped regresta. Obtinut: {rezultat!r}"
|
||||
)
|
||||
assert "OP-99" in rezultat
|
||||
|
||||
|
||||
def test_parse_erori_gol_returneaza_lista_goala():
|
||||
"""BUG 2: parse_erori pe dict/lista goala -> [], nu 1 element cu problema=''."""
|
||||
r1 = parse_erori("{}")
|
||||
assert r1 == [], f"parse_erori('{{}}') -> {r1!r}, asteptat []"
|
||||
|
||||
r2 = parse_erori("[{}]")
|
||||
assert r2 == [], f"parse_erori('[{{}}]') -> {r2!r}, asteptat []"
|
||||
86
tests/test_web_header_menu.py
Normal file
86
tests/test_web_header_menu.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Teste US-006 (PRD 5.5): meniu hamburger in header (Cont/Integrare/Nomenclator/Admin/logout)
|
||||
+ context de autentificare. base.html partajat: pe login/signup meniul nu expune cont/logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "menu_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _make_user(email="u@test.com", password="parolasecreta10", is_admin=False):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test", active=True)
|
||||
create_user(conn, acct_id, email, password, is_admin=is_admin)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email="u@test.com", password="parolasecreta10"):
|
||||
resp = client.get("/login")
|
||||
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def test_meniu_autentificat_are_linkuri_cont(client):
|
||||
_make_user()
|
||||
_login(client)
|
||||
html = client.get("/").text
|
||||
# butonul de meniu (hamburger) prezent
|
||||
assert 'id="cont-menu-toggle"' in html
|
||||
assert 'aria-controls="cont-menu"' in html
|
||||
# linkurile mutate in meniu
|
||||
assert 'href="/?tab=cont"' in html
|
||||
assert 'href="/?tab=integrare"' in html
|
||||
assert 'href="/?tab=nomenclator"' in html
|
||||
# logout in meniu
|
||||
assert 'action="/logout"' in html
|
||||
assert "Iesi din cont" in html
|
||||
|
||||
|
||||
def test_meniu_admin_link_doar_pentru_admin(client):
|
||||
_make_user(email="admin@test.com", is_admin=True)
|
||||
_login(client, email="admin@test.com")
|
||||
html = client.get("/").text
|
||||
assert 'href="/admin"' in html
|
||||
|
||||
|
||||
def test_meniu_fara_admin_pentru_neadmin(client):
|
||||
_make_user(email="plain@test.com", is_admin=False)
|
||||
_login(client, email="plain@test.com")
|
||||
html = client.get("/").text
|
||||
assert 'href="/admin"' not in html
|
||||
|
||||
|
||||
def test_meniu_neautentificat_fara_logout(client):
|
||||
"""Pe /login (neautentificat) meniul nu expune cont/logout."""
|
||||
html = client.get("/login").text
|
||||
assert "Iesi din cont" not in html
|
||||
assert 'action="/logout"' not in html
|
||||
assert 'id="cont-menu-toggle"' not in html
|
||||
180
tests/test_web_import_erori.py
Normal file
180
tests/test_web_import_erori.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Teste US-007 (PRD 5.4): erori de import pe 3 niveluri in interfata web.
|
||||
|
||||
Verifica ca fragmentele HTML intoarse de rutele web /_import/* contin
|
||||
textele structurate (problema + fix) din catalog pentru erorile de upload
|
||||
si de mapare coloane.
|
||||
|
||||
Rutele testate sunt WEB (HTML), nu API JSON — raspunsul este HTML fragment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixture client cu WEB_AUTH_REQUIRED=false (dev mode, cont 1 implicit) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client FastAPI cu DB temporara izolata, auth web dezactivat (dev mode)."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori3n.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.crypto import reset_cache
|
||||
reset_cache()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
reset_cache()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _make_xlsx_prea_mare(n_randuri: int = 5001) -> bytes:
|
||||
"""Xlsx cu mai mult de 5000 de randuri de date (declanseaza FileTooLarge)."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"])
|
||||
for i in range(n_randuri):
|
||||
ws.append([
|
||||
f"WVWZZZ1KZA{i:07d}",
|
||||
f"B{i:04d}TST",
|
||||
"2026-06-15",
|
||||
str(100000 + i),
|
||||
"Revizie",
|
||||
])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload_web(client: TestClient, data: bytes, filename: str = "test.xlsx") -> object:
|
||||
"""POST pe ruta web de upload (intoarce HTML fragment)."""
|
||||
return client.post(
|
||||
"/_import/upload",
|
||||
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Upload fisier prea mare → fragment cu fix din IMPORT_FISIER_PREA_MARE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_fisier_prea_mare_3niveluri(client):
|
||||
"""Upload xlsx >5000 randuri → fragment HTML contine textul fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['IMPORT_FISIER_PREA_MARE']['fix']:
|
||||
'Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand.'
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["IMPORT_FISIER_PREA_MARE"]["fix"]
|
||||
|
||||
data = _make_xlsx_prea_mare(5001)
|
||||
r = _upload_web(client, data, "mare.xlsx")
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din IMPORT_FISIER_PREA_MARE nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Upload fisier nerecunoscut → fragment cu fix din IMPORT_FISIER_NERECUNOSCUT
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_fisier_nerecunoscut_3niveluri(client):
|
||||
"""Upload bytes invalizi (nu e xlsx/csv valid) → fragment HTML cu fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['IMPORT_FISIER_NERECUNOSCUT']['fix']:
|
||||
'Incarca un fisier .xlsx sau .csv valid.'
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["fix"]
|
||||
problema_asteptata = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["problema"]
|
||||
|
||||
# Bytes invalizi: nu este un zip/xlsx, nu este text CSV
|
||||
date_invalide = b"\x00\x01\x02\x03\x04\x05binar_junk_non_xlsx"
|
||||
r = _upload_web(client, date_invalide, "test.xlsx")
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din IMPORT_FISIER_NERECUNOSCUT nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Mapare coloane cu JSON invalid → fragment cu fix din COLOANE_FORMAT_JSON #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_mapcoloane_format_json_3niveluri(client):
|
||||
"""POST direct pe ruta de mapare coloane cu corp JSON invalid → fragment cu fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['COLOANE_FORMAT_JSON']['fix']:
|
||||
'Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect).'
|
||||
|
||||
Ruta /_import/{id}/mapare-coloane accepta form data. Codul de test simuleaza
|
||||
un batch_id invalid (0) pentru a forta ramura de eroare, sau injecteaza direct
|
||||
un batch_id valid cu JSON invalid in campul de mapare. Deoarece ruta nu primeste
|
||||
JSON direct, testam ramura din routes.py unde se face json.dumps si se valideaza
|
||||
manual, sau modificam testul sa plaseze un batch valid si sa trimita date malformate.
|
||||
|
||||
Strategia: upload un fisier valid, obtine import_id, trimite un form cu valoare
|
||||
json invalida in campul coloane (via Content-Type: application/json intentionat gresit
|
||||
sau via parametru form malformat). In implementarea din routes.py, eroarea
|
||||
COLOANE_FORMAT_JSON este randata cand json.loads esueaza pe un camp special.
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["COLOANE_FORMAT_JSON"]["fix"]
|
||||
|
||||
# Primul pas: upload un fisier valid pentru a obtine un import_id real
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["VIN", "Nr", "Data", "KM", "Op"])
|
||||
ws.append(["WVWZZZ1KZAW000123", "B001TST", "2026-06-15", "100000", "Revizie"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
xlsx_data = buf.getvalue()
|
||||
|
||||
r_upload = _upload_web(client, xlsx_data, "test.xlsx")
|
||||
assert r_upload.status_code == 200, r_upload.text[:300]
|
||||
|
||||
# Extrage import_id din raspuns
|
||||
import re
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r_upload.text)
|
||||
assert m, f"Nu s-a gasit import_id in raspuns: {r_upload.text[:500]}"
|
||||
import_id = int(m.group(1))
|
||||
|
||||
# Trimite un request pe ruta de mapare coloane cu Content-Type: application/json
|
||||
# si corp JSON invalid → ruta ar trebui sa intoarca eroarea COLOANE_FORMAT_JSON
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
content=b"{invalid json}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din COLOANE_FORMAT_JSON nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
@@ -28,12 +28,17 @@ def _starile_din_schema() -> list[str]:
|
||||
sql = schema_path.read_text(encoding="utf-8")
|
||||
|
||||
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
|
||||
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii.
|
||||
# Nota (5.5): de cand exista si `accounts.status` cu propriul CHECK, ancoram pe blocul
|
||||
# tabelei submissions (`CREATE TABLE ... submissions`) ca sa nu prindem starile de cont.
|
||||
tbl = re.search(
|
||||
r"CREATE TABLE[^;]*?submissions\b(.*?);", sql, re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
assert tbl, "Nu am gasit CREATE TABLE submissions in schema.sql — schema s-a schimbat?"
|
||||
match = re.search(
|
||||
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
|
||||
sql,
|
||||
tbl.group(1),
|
||||
)
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in submissions — schema s-a schimbat?"
|
||||
|
||||
raw = match.group(1)
|
||||
# Extrage valorile dintre ghilimele simple
|
||||
|
||||
@@ -73,7 +73,7 @@ def client(monkeypatch):
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
|
||||
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
|
||||
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
|
||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
||||
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
|
||||
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
|
||||
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
|
||||
# Doar Acasa + Mapari sunt tab-uri (role="tab")
|
||||
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
|
||||
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
|
||||
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
|
||||
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
|
||||
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
|
||||
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
|
||||
# ...dar traiesc in meniul de cont
|
||||
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -215,3 +218,20 @@ def test_tabbar_aria(client):
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_fragmentele_mutate_raman_accesibile (US-007)
|
||||
# ============================================================
|
||||
|
||||
def test_fragmentele_mutate_raman_accesibile(client):
|
||||
"""US-007 (5.5): Cont/Integrare/Nomenclator s-au mutat in meniu, dar rutele de fragment
|
||||
si deep-link-ul ?tab= raman valide (zero rute moarte / 404)."""
|
||||
_create_account_user("frag@test.com", "parolasecreta10")
|
||||
_login(client, "frag@test.com", "parolasecreta10")
|
||||
|
||||
for tab in ("cont", "integrare", "nomenclator"):
|
||||
r_frag = client.get(f"/_fragments/{tab}")
|
||||
assert r_frag.status_code == 200, f"/_fragments/{tab} a dat {r_frag.status_code}"
|
||||
r_deep = client.get(f"/?tab={tab}")
|
||||
assert r_deep.status_code == 200, f"/?tab={tab} a dat {r_deep.status_code}"
|
||||
|
||||
171
tests/test_web_uniformizare.py
Normal file
171
tests/test_web_uniformizare.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
|
||||
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja
|
||||
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
_TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "web" / "templates"
|
||||
|
||||
|
||||
def _env():
|
||||
return Environment(loader=FileSystemLoader(str(_TEMPLATES)), autoescape=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-002: Nomenclator ca tabel standard (grila Trimiteri)
|
||||
# ============================================================
|
||||
|
||||
def test_nomenclator_grila_standard_cu_randuri():
|
||||
tmpl = _env().get_template("_nomenclator.html")
|
||||
html = tmpl.render(rows=[
|
||||
{"cod_prestatie": "A012", "nume_prestatie": "Revizie tehnica", "updated_at": "2026-06-20"},
|
||||
])
|
||||
assert "tablewrap" in html
|
||||
assert "<table" in html
|
||||
assert 'class="pill"' in html # codul in pill, ca la Trimiteri
|
||||
assert "A012" in html and "Revizie tehnica" in html
|
||||
|
||||
|
||||
def test_nomenclator_empty_state():
|
||||
tmpl = _env().get_template("_nomenclator.html")
|
||||
html = tmpl.render(rows=[])
|
||||
assert 'class="empty"' in html
|
||||
assert "Nomenclator gol" in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-003: macro autosend_toggle compact (Auto/Manual)
|
||||
# ============================================================
|
||||
|
||||
def _render_macro(form_id="map-1", checked=True):
|
||||
mod = _env().get_template("_macros.html").module
|
||||
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
|
||||
|
||||
|
||||
def test_autosend_pastreaza_name_si_prezenta():
|
||||
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta)."""
|
||||
html = _render_macro(checked=True)
|
||||
assert 'type="checkbox"' in html
|
||||
assert 'name="auto_send"' in html
|
||||
assert 'value="true"' in html
|
||||
assert 'form="map-1"' in html
|
||||
assert "checked" in html
|
||||
|
||||
|
||||
def test_autosend_nebifat_fara_checked():
|
||||
html = _render_macro(checked=False)
|
||||
assert 'name="auto_send"' in html
|
||||
assert "checked" not in html
|
||||
|
||||
|
||||
def test_autosend_compact_fara_proza_inline():
|
||||
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in
|
||||
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
|
||||
inainte de verificare."""
|
||||
html = _render_macro()
|
||||
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip)
|
||||
assert "La fisierele viitoare" not in vizibil
|
||||
assert "Tine pentru verificare" not in vizibil
|
||||
assert "nimic nu pleaca la RAR" not in vizibil
|
||||
# ambele etichete de stare vizibile, compact
|
||||
assert "Auto" in html and "Manual" in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-001: Acasa fara sectiunea Ajutor
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "uniform_test.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _login(client, email="u@test.com", password="parolasecreta10"):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
finally:
|
||||
conn.close()
|
||||
resp = client.get("/login")
|
||||
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
|
||||
assert resp.status_code == 303
|
||||
return acct_id
|
||||
|
||||
|
||||
def test_acasa_fara_sectiune_ajutor(client):
|
||||
_login(client)
|
||||
resp = client.get("/_fragments/acasa")
|
||||
assert resp.status_code == 200
|
||||
# randul "Ajutor:" cu wayfinding Mapari/Coduri RAR eliminat din Acasa
|
||||
assert "Ajutor:" not in resp.text
|
||||
assert "Coduri RAR" not in resp.text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# US-005: Tabel Mapari standardizat + panou Ajutor
|
||||
# ============================================================
|
||||
|
||||
def _seed_needs_mapping(acct_id, cod_op="OP-NM", denumire="Operatie test"):
|
||||
import json
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?)",
|
||||
(f"k-{cod_op}", acct_id,
|
||||
json.dumps({"prestatii": [{"cod_op_service": cod_op, "denumire": denumire}]})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
|
||||
acct = _login(client)
|
||||
_seed_needs_mapping(acct)
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# panou Ajutor (<details>) prezent
|
||||
assert "ajutor-mapari" in html
|
||||
assert "<details" in html and ">Ajutor<" in html
|
||||
# antet de coloana compact
|
||||
assert ">In coada<" in html
|
||||
# proza inline veche eliminata de pe sectiuni
|
||||
assert "sugestia fuzzy e preselectata) si salveaza" not in html
|
||||
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
|
||||
|
||||
|
||||
def test_mapari_comutator_compact_in_tabel(client):
|
||||
acct = _login(client)
|
||||
_seed_needs_mapping(acct)
|
||||
html = client.get("/_fragments/mapari").text
|
||||
assert 'name="auto_send"' in html
|
||||
assert "Manual" in html and "Auto" in html
|
||||
@@ -125,6 +125,51 @@ def test_claim_account_null_tratat_activ(env):
|
||||
assert _row_status(conn, sid) == "sending"
|
||||
|
||||
|
||||
def test_claim_sare_cont_blocat(env):
|
||||
"""5.5: cont blocked -> claim_one nu ridica submission-ul."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Blocat", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "blocked")
|
||||
|
||||
assert claim_one(conn) is None
|
||||
assert _row_status(conn, sid) == "queued"
|
||||
|
||||
|
||||
def test_claim_sare_cont_arhivat(env):
|
||||
"""5.5: cont archived -> claim_one nu ridica submission-ul."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Arhivat", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "archived")
|
||||
|
||||
assert claim_one(conn) is None
|
||||
assert _row_status(conn, sid) == "queued"
|
||||
|
||||
|
||||
def test_deblocare_reia_trimiterea(env):
|
||||
"""5.5: blocked -> set_status('active') -> claim_one ridica din nou."""
|
||||
from app.accounts import create_account, set_status
|
||||
from app.worker.__main__ import claim_one
|
||||
|
||||
conn, _ = env
|
||||
acct_id = create_account(conn, "Service Revenit", active=True)
|
||||
sid = _insert(conn, account_id=acct_id)
|
||||
set_status(conn, acct_id, "blocked")
|
||||
assert claim_one(conn) is None
|
||||
|
||||
set_status(conn, acct_id, "active")
|
||||
result = claim_one(conn)
|
||||
assert result is not None and result["id"] == sid
|
||||
assert _row_status(conn, sid) == "sending"
|
||||
|
||||
|
||||
def test_claim_cont_legacy_fara_active(env):
|
||||
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
|
||||
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.
|
||||
|
||||
245
tests/test_worker_rar_errors.py
Normal file
245
tests/test_worker_rar_errors.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Teste US-004 — erorile RAR (400/401) imbracate pe 3 niveluri in worker.
|
||||
|
||||
process_one cu 400 -> rar_error superset [{field,message,cod,problema,cauza,fix}]
|
||||
run() loop cu 401 la login -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE
|
||||
Clasificare transient/terminal neschimbata.
|
||||
Fara echo de credentiale in rar_error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.rar_client import RarAuthError, RarError
|
||||
|
||||
|
||||
# --- Fixtures DB (pattern din test_worker_reconcile.py) ---
|
||||
|
||||
@pytest.fixture()
|
||||
def env(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.db import get_connection, init_db
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
settings = get_settings()
|
||||
yield conn, settings
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
_CONTENT = {
|
||||
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
|
||||
}
|
||||
|
||||
|
||||
def _insert(conn, status="queued", content=None, retry_count=0):
|
||||
content = content or _CONTENT
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), retry_count),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def _row(conn, sid):
|
||||
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||
|
||||
|
||||
class FakeRar:
|
||||
"""RAR mock cu comportament configurabil."""
|
||||
def __init__(self, *, finalizate=None, post_result=None, post_exc=None):
|
||||
self.finalizate = finalizate or []
|
||||
self.post_result = post_result if post_result is not None else {"id": 1000}
|
||||
self.post_exc = post_exc
|
||||
self.post_calls = 0
|
||||
self.finalizate_calls = 0
|
||||
|
||||
def get_finalizate(self, token):
|
||||
self.finalizate_calls += 1
|
||||
return self.finalizate
|
||||
|
||||
def post_prezentare(self, token, payload):
|
||||
self.post_calls += 1
|
||||
if self.post_exc is not None:
|
||||
raise self.post_exc
|
||||
return self.post_result
|
||||
|
||||
|
||||
# --- Teste US-004 ---
|
||||
|
||||
def test_rar_400_stocheaza_3niveluri(env):
|
||||
"""RAR 400 cu field_errors -> rar_error superset array cu cod/problema/fix/message."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
exc = RarError("Validare esuata", status_code=400,
|
||||
field_errors=[{"field": "vin", "message": "VIN invalid la RAR"}])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "needs_data"
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "needs_data"
|
||||
assert row["rar_status_code"] == 400
|
||||
|
||||
erori = json.loads(row["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) == 1
|
||||
|
||||
elem = erori[0]
|
||||
# Superset: field + message (vechi) + 3 niveluri (noi)
|
||||
assert elem["field"] == "vin"
|
||||
assert elem["message"] == "VIN invalid la RAR" # passthrough exact mesaj RAR
|
||||
assert elem["cod"] == "RAR_VALIDARE"
|
||||
assert elem["problema"] # ne-gol
|
||||
assert elem["fix"] # ne-gol
|
||||
assert elem["cauza"] == "VIN invalid la RAR" # cauza = mesajul RAR exact
|
||||
|
||||
|
||||
def test_rar_400_fara_field_errors(env):
|
||||
"""RAR 400 cu field_errors=[] -> array cu 1 element RAR_VALIDARE, cauza din str(exc)."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
exc = RarError("Eroare generica RAR", status_code=400, field_errors=[])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "needs_data"
|
||||
erori = json.loads(_row(conn, sid)["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) == 1
|
||||
|
||||
elem = erori[0]
|
||||
assert elem["cod"] == "RAR_VALIDARE"
|
||||
assert elem["cauza"] == "Eroare generica RAR" # str(exc)
|
||||
assert elem["problema"]
|
||||
assert elem["fix"]
|
||||
|
||||
|
||||
def test_rar_401_creds_3niveluri(env):
|
||||
"""Login esueaza cu RarAuthError -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE."""
|
||||
from app.worker.__main__ import mark, process_one
|
||||
conn, settings = env
|
||||
|
||||
# Simulam fluxul din run(): login esueaza cu RarAuthError, workerul marcheaza direct.
|
||||
# Testam handler-ul login din run() prin apel direct la mark() asa cum il face workerul.
|
||||
sid = _insert(conn, status="sending")
|
||||
|
||||
# Apelam direct mark() cu JSON-ul pe care workerul il va produce (dupa implementare)
|
||||
from app.errors import eroare
|
||||
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "error"
|
||||
err = json.loads(row["rar_error"])
|
||||
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||
assert err["problema"]
|
||||
assert err["fix"]
|
||||
assert err["cauza"] == "credentiale RAR invalide"
|
||||
|
||||
|
||||
def test_rar_401_login_in_run_loop(env):
|
||||
"""run() loop: RarAuthError la login -> submission 'error' cu rar_error JSON 3-niveluri."""
|
||||
from app.worker.__main__ import AccountSessions, _creds_for, _creds_from_account, mark, requeue_with_backoff
|
||||
conn, settings = env
|
||||
sid = _insert(conn, status="queued")
|
||||
|
||||
# Simuleaza comportamentul din run() pentru blocul try: sessions.get_token cu RarAuthError
|
||||
# Acesta e acelasi cod pe care il implementam; testam efectul final pe DB.
|
||||
|
||||
class FakeSessionsLoginFail:
|
||||
def get_token(self, conn, account_id, creds):
|
||||
raise RarAuthError("Credentiale RAR invalide", status_code=401)
|
||||
|
||||
import json as _json
|
||||
from app.errors import eroare as _eroare
|
||||
|
||||
try:
|
||||
FakeSessionsLoginFail().get_token(conn, 1, {"email": "x@x.com", "password": "secret123"})
|
||||
except RarAuthError:
|
||||
err_json = _json.dumps(_eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "error"
|
||||
err = _json.loads(row["rar_error"])
|
||||
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||
assert err["problema"]
|
||||
assert err["fix"]
|
||||
# Fara retry: statusul ramane error (nu queued)
|
||||
assert row["retry_count"] == 0
|
||||
|
||||
|
||||
def test_clasificare_transient_neschimbata(env):
|
||||
"""5xx ramane transient: requeue cu retry, NU error terminal."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
rar = FakeRar(finalizate=[], post_exc=RarError("server error", status_code=503))
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "requeued"
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "queued" # requeue, nu error
|
||||
assert int(row["retry_count"]) == 1 # a incrementat retry
|
||||
|
||||
# Timeout httpx -> de asemenea transient
|
||||
sid2 = _insert(conn)
|
||||
rar2 = FakeRar(finalizate=[], post_exc=httpx.ConnectTimeout("timeout"))
|
||||
out2 = process_one(conn, settings, rar2, "tok", {"id": sid2, "content": _CONTENT})
|
||||
assert out2 == "requeued"
|
||||
assert _row(conn, sid2)["status"] == "queued"
|
||||
|
||||
|
||||
def test_fara_echo_creds(env):
|
||||
"""rar_error nu contine valori concrete de credentiale (email/parola reala).
|
||||
|
||||
Textul 'parola' poate aparea in mesajele de ajutor (fix) — e de asteptat.
|
||||
Verificam ca parola/email-ul concret al utilizatorului NU apare in rar_error.
|
||||
"""
|
||||
from app.worker.__main__ import mark, process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn, status="sending")
|
||||
|
||||
# O parola concreta fictiva care NU trebuie sa apara niciodata in rar_error
|
||||
parola_concreta = "SuperSecret$789!"
|
||||
email_concret = "user@exemplu.ro"
|
||||
|
||||
from app.errors import eroare
|
||||
# Simuleaza eroarea 401 — cauza e generica, nu contine parola concreta
|
||||
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
rar_error_str = row["rar_error"]
|
||||
# Parola concreta si email-ul concret NU trebuie sa apara
|
||||
assert parola_concreta not in rar_error_str
|
||||
assert email_concret not in rar_error_str
|
||||
# Campul "password" (cheia JSON) nu trebuie sa apara — ar insemna ca obiectul creds a fost serializat
|
||||
assert '"password"' not in rar_error_str
|
||||
|
||||
# Verifica si pentru 400 — mesajul RAR nu contine parola utilizatorului
|
||||
sid2 = _insert(conn)
|
||||
exc = RarError("Validare", status_code=400, field_errors=[{"field": "vin", "message": "bad vin"}])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
process_one(conn, settings, rar, "tok", {"id": sid2, "content": _CONTENT})
|
||||
rar_error_400 = _row(conn, sid2)["rar_error"]
|
||||
assert parola_concreta not in rar_error_400
|
||||
assert '"password"' not in rar_error_400
|
||||
Reference in New Issue
Block a user