Compare commits
5 Commits
3bc0825e0b
...
c31a1e254c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31a1e254c | ||
|
|
4a2afc68bf | ||
|
|
f05fe5b221 | ||
|
|
074b6e7c8a | ||
|
|
5a964a1a8d |
150
DESIGN.md
Normal file
150
DESIGN.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# DESIGN.md — Sistem de design AutoPass (by ROMFAST)
|
||||
|
||||
> Sursa de adevar pentru identitatea vizuala a dashboard-ului. Implementarea concreta sta in
|
||||
> `app/web/templates/base.html` (variabile CSS `:root` + `[data-theme="light"]`). Acest fisier
|
||||
> spune *ce* si *de ce*; base.html spune *cum*.
|
||||
|
||||
## Lucrul de retinut
|
||||
|
||||
> „Software serios pentru o obligatie legala serioasa — dar parte din familia ROMFAST/ROA, nu un
|
||||
> tool anonim." Operatorul de service trebuie sa simta ca declara la stat printr-un instrument de
|
||||
> incredere, cu identitatea producatorului (ROMFAST) prezenta discret, nu griul generic de azi.
|
||||
|
||||
## Context produs
|
||||
|
||||
Gateway web care declara prezentari de service-auto la RAR AUTOPASS (L.142/2023). Utilizatori:
|
||||
operatori de service-auto si integratori ROAAUTO. Face parte din familia **ROA — Romfast
|
||||
Applications** (ERP romanesc, modul Service Auto). Referinta de brand: **romfast.ro** — alb curat,
|
||||
accent albastru azur, pill-uri rotunjite, comutator de tema, logo rosu+albastru.
|
||||
|
||||
## Decizie cromatica
|
||||
|
||||
Accentul functional = **albastrul ROMFAST** (acelasi cu „FAST" din logo si cu accentul de pe
|
||||
romfast.ro), nu albastrul generic SaaS de pana acum. Rosul apare DOAR in wordmark-ul „ROM" — nu ca
|
||||
accent de UI, fiindca rosul e rezervat starilor de eroare. Un singur accent, restul neutre, ca
|
||||
sistemul sa ramana discret.
|
||||
|
||||
### Paleta — Dark (default)
|
||||
|
||||
```
|
||||
--bg: #0f1218 fundal aplicatie
|
||||
--card: #181c24 suprafete (carduri, modal, inputuri pe fundal)
|
||||
--ink: #e6e9ef text principal
|
||||
--muted: #8b93a7 text secundar (label-uri, coduri, „by")
|
||||
--line: #262b36 borduri, separatoare
|
||||
--accent:#2E74D6 azur ROMFAST — butoane primare, pill activ, linkuri, focus
|
||||
--ok: #2FBF8F sent / succes
|
||||
--warn: #E0A93B sending / atentie / Lipsa cod
|
||||
--err: #E05D5D error / needs_data / Date incomplete
|
||||
```
|
||||
|
||||
### Paleta — Light (`[data-theme="light"]`)
|
||||
|
||||
```
|
||||
--bg: #f5f7fa fundal (alb-rece ca romfast.ro)
|
||||
--card: #ffffff suprafete
|
||||
--ink: #1a1d24 text principal
|
||||
--muted: #5c6473 text secundar
|
||||
--line: #e2e5ea borduri
|
||||
--accent:#1F66C9 azur, variantă mai inchisa pentru contrast AA pe alb
|
||||
--ok: #15803d verde AA pe alb
|
||||
--warn: #b45309 chihlimbar AA pe alb
|
||||
--err: #dc2626 rosu AA pe alb
|
||||
```
|
||||
|
||||
### Paleta — Petrol (`[data-theme="petrol"]`, tema selectabila)
|
||||
|
||||
Tema intunecata alternativa, cu accent petrol-teal (directia initiala aleasa, pastrata ca optiune).
|
||||
Aceleasi neutre-calde inchise; doar accentul difera de azur.
|
||||
|
||||
```
|
||||
--bg: #0e1416 fundal petrol-inchis
|
||||
--card: #161e20 suprafete
|
||||
--ink: #e6e9ef text principal
|
||||
--muted: #8b93a7 text secundar
|
||||
--line: #232c2e borduri
|
||||
--accent:#0E7C7B teal petrol — butoane, pill activ, linkuri, focus
|
||||
--ok: #2FBF8F sent
|
||||
--warn: #E0A93B atentie
|
||||
--err: #E05D5D eroare
|
||||
```
|
||||
|
||||
### Culori de brand (doar wordmark, NU variabile de UI)
|
||||
|
||||
```
|
||||
ROM: #D1342F rosu logo
|
||||
FAST: #2E74D6 albastru logo (= accentul de UI in dark)
|
||||
```
|
||||
|
||||
Contrast: textul principal pe fundal ramane AA in ambele teme; accentul pe alb foloseste varianta
|
||||
mai inchisa (`#1F66C9`) ca text/linkul sa treaca AA.
|
||||
|
||||
## Tipografie
|
||||
|
||||
- **UI / titluri**: **IBM Plex Sans** — sans-serif cu caracter ingineresc, open-source, potrivit
|
||||
pentru „software serios", parte din limbajul vizual tehnic. Fallback: `system-ui, sans-serif`.
|
||||
- **Coduri / monospace**: **IBM Plex Mono** — pentru coduri RAR (REV2), VIN, numar inmatriculare,
|
||||
detalii tehnice. Inlocuieste `ui-monospace/Menlo` actual cu o familie coerenta cu UI-ul.
|
||||
- **Incarcare**: self-host `woff2` (subset latin + latin-ext pentru diacritice romanesti) in
|
||||
`app/web/static/fonts/`, `font-display: swap`. Fara CDN extern (gateway intern, fara dependente
|
||||
de retea la runtime). Pana la self-host, fallback la stiva de sistem nu strica layout-ul.
|
||||
|
||||
## Header & branding
|
||||
|
||||
- Titlul „Gateway RAR AUTOPASS" **centrat** pe header.
|
||||
- Sub titlu, mic: **logo-ul ROMFAST** (`/static/romfast_logo.png`, ~28px inaltime). Decizie user
|
||||
(2026-06-25, US-012b): se foloseste PNG-ul real al logo-ului (ROM rosu + FAST albastru, fundal
|
||||
transparent — lizibil pe light/dark/petrol), NU wordmark-ul text. Wordmark-ul text (`by ROM FAST`
|
||||
cu `ROM #D1342F` / `FAST #2E74D6`) ramane documentat ca alternativa, dar livrabila finala
|
||||
foloseste imaginea.
|
||||
- Controalele (comutator tema, versiune, hamburger ☰) raman la **dreapta**, fara a strica
|
||||
centrarea optica a titlului (ex. grila 3 coloane: stanga goala/echilibru, centru titlu, dreapta
|
||||
controale).
|
||||
- Responsiv: pe mobil, wordmark-ul ramane sub titlu; controalele nu se suprapun (degrada elegant,
|
||||
eventual titlu mai mic).
|
||||
|
||||
## Selector de tema
|
||||
|
||||
Inlocuieste comutatorul binar soare/luna cu un **buton ciclic** (pattern ca demoanaf.ro): un
|
||||
singur buton care roteste la fiecare click prin setul de teme, cu iconita + tooltip/`aria-label`
|
||||
care arata tema curenta („Tema: Light" etc.).
|
||||
|
||||
Ordinea ciclului: **Light → Dark → Petrol → Auto → (inapoi la Light)**.
|
||||
|
||||
- `Light` → `data-theme="light"` (azur pe alb)
|
||||
- `Dark` → `data-theme="dark"` (azur pe inchis, comportamentul implicit actual)
|
||||
- `Petrol` → `data-theme="petrol"` (teal pe petrol-inchis)
|
||||
- `Auto` → urmeaza `prefers-color-scheme`; rezolva la Light azur sau Dark azur in functie de OS
|
||||
(nu seteaza `data-theme` fix, ci il deriva la paint).
|
||||
|
||||
Persistenta: preferinta explicita (inclusiv „Auto") in `localStorage`, doar la click. Scriptul
|
||||
anti-FOUC din `<head>` trebuie sa rezolve „Auto"→light/dark inainte de primul paint (fara blink).
|
||||
Iconite: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto. Default la prima vizita = Auto (OS-aware), ca azi.
|
||||
|
||||
## Componente — note de aplicare
|
||||
|
||||
- **Pill-uri de stare/filtru**: rotunjite (`border-radius:99px`), ca badge-ul „ROA" de pe
|
||||
romfast.ro. Pill activ = fundal accent discret (`color-mix(in srgb, var(--accent) ...)`), text
|
||||
pe accent. Categoriile de problema isi pastreaza registrul: Date incomplete/Eroare = `--err`,
|
||||
Lipsa cod = `--warn`.
|
||||
- **Butoane primare**: fundal `--accent`, text alb (neschimbat ca structura, doar culoarea noua).
|
||||
- **Linkuri / sugestii**: `--accent`.
|
||||
- **Focus**: `outline:2px solid var(--accent)` (deja folosit pe randuri).
|
||||
- **Suprafete de stare** (banner, flash, eroare-3n): raman pe `color-mix` peste `--err/--warn/--ok`,
|
||||
deci se adapteaza automat la noua paleta si la light/dark.
|
||||
|
||||
## Ce NU schimbam
|
||||
|
||||
- Mecanismul light/dark existent (anti-FOUC, persistenta `localStorage`, comutator) — il pastram,
|
||||
doar reimprospatam variabilele.
|
||||
- Nu introducem rosu ca accent de UI (conflict cu eroare).
|
||||
- ~~Nu folosim PNG-ul logo cu efect 3D in interfata (wordmark redat ca text).~~ REVIZUIT
|
||||
(decizie user 2026-06-25): logo-ul PNG real e folosit in header (US-012b). Fundal transparent +
|
||||
culori proprii il fac lizibil pe toate temele; nu aplicam filtre.
|
||||
- Nu adaugam un al doilea accent — sistemul ramane monocrom-accent + neutre.
|
||||
|
||||
## Legatura cu implementarea (PRD 5.10)
|
||||
|
||||
US-012 (header „by ROMFAST" + titlu centrat) si US-013 (paleta) din
|
||||
`docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md` implementeaza acest sistem. Valorile de
|
||||
mai sus sunt sursa pentru variabilele din `base.html`.
|
||||
@@ -8,14 +8,13 @@ Endpointuri:
|
||||
POST /v1/import/{id}/commit — gate HARD + enqueue randuri ok + log atestare
|
||||
GET /v1/import/{id}/export-failed — CSV cu randuri esuate (needs_data/needs_mapping/needs_review)
|
||||
|
||||
Reguli cheie (plan §3.1-3.4, §12):
|
||||
- Issue 6: scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
||||
- Eng#5: already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
||||
- OV-3: duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
||||
- Issue 1 (TOCTOU): commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
- Issue 5a: import_rows.raw_json CRIPTAT Fernet.
|
||||
- Issue 5b: fuzzy coloane refoloseste mapping.normalize_for_match (DRY).
|
||||
- T4/D3: drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
||||
Reguli cheie:
|
||||
- Scrieri bulk in tranzactie explicita BEGIN IMMEDIATE...COMMIT + executemany.
|
||||
- already_sent lookup BATCH (IN chunk ~900), nu N+1.
|
||||
- duplicate_in_file EXCLUSIV la preview/commit. NU atinge reconcile.py/worker.
|
||||
- TOCTOU: commit per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
- import_rows.raw_json CRIPTAT Fernet.
|
||||
- Drift semnatura coloane -> NU aplica orb, cere re-confirmare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,7 +59,7 @@ router = APIRouter(prefix="/v1/import", tags=["import"])
|
||||
# Marimea maxima a unui chunk pentru IN(...) SQLite (limite SQLite ~999)
|
||||
_IN_CHUNK = 900
|
||||
|
||||
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane (Issue 5b/Eng#4)
|
||||
# Campuri canonice si sinonimele lor pentru sugestie fuzzy coloane
|
||||
_CANONICAL_SYNONYMS: dict[str, list[str]] = {
|
||||
"vin": ["VIN", "Serie sasiu", "Sasiu", "Serie", "Numar sasiu", "Nr sasiu", "Chassis"],
|
||||
"nr_inmatriculare": ["Nr inmatriculare", "Numar inmatriculare", "Numar auto", "Nr auto", "Numar", "Nr"],
|
||||
@@ -93,7 +92,7 @@ def _fuzzy_suggest_column(
|
||||
) -> list[dict]:
|
||||
"""Sugereaza campuri canonice pentru o coloana din fisier.
|
||||
|
||||
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio (Issue 5b/Eng#4).
|
||||
Refoloseste normalize_for_match + rapidfuzz.fuzz.token_sort_ratio.
|
||||
Intoarce [{camp_canonic, score}] sortat descrescator.
|
||||
"""
|
||||
from rapidfuzz import fuzz, process
|
||||
@@ -140,10 +139,10 @@ def _resolve_row_for_preview(
|
||||
errors: lista erori validare
|
||||
flags: motive needs_review
|
||||
|
||||
`override` (3.6, Approach B): patch CANONIC editat in preview, aplicat ULTIMUL
|
||||
peste valorile mapate (dupa `json_mapare` si canonicalizare). Permite corectarea
|
||||
unei valori sau completarea unui camp a carui coloana LIPSESTE din fisier, fara
|
||||
sa atinga `raw_json`/idempotency.
|
||||
`override`: patch CANONIC editat in preview, aplicat ULTIMUL peste valorile
|
||||
mapate (dupa `json_mapare` si canonicalizare). Permite corectarea unei valori
|
||||
sau completarea unui camp a carui coloana LIPSESTE din fisier, fara sa atinga
|
||||
`raw_json`/idempotency.
|
||||
"""
|
||||
# Aplica maparea de coloane
|
||||
mapped: dict[str, Any] = {}
|
||||
@@ -151,7 +150,7 @@ def _resolve_row_for_preview(
|
||||
if col_fisier in raw_row and camp_canonic:
|
||||
mapped[camp_canonic] = raw_row[col_fisier]
|
||||
|
||||
# Detectie coloane cu formule (Issue 3) — nu blocheaza, dar adauga flag
|
||||
# Detectie coloane cu formule — nu blocheaza, dar adauga flag
|
||||
formula_flag: list[str] = []
|
||||
for col_fisier, camp_canonic in json_mapare.items():
|
||||
if col_fisier in formula_columns:
|
||||
@@ -186,7 +185,7 @@ def _resolve_row_for_preview(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# Canonicalizare (T9): normalizeaza VIN/nr/odometru
|
||||
# Canonicalizare: normalizeaza VIN/nr/odometru
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
"vin": canon["vin"],
|
||||
@@ -194,7 +193,7 @@ def _resolve_row_for_preview(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview (3.6) — aplicat ULTIMUL, peste valorile mapate +
|
||||
# Override editat in preview — aplicat ULTIMUL, peste valorile mapate +
|
||||
# canonicalizate. Valorile din override sunt deja canonice (vezi _merge_override).
|
||||
if override:
|
||||
mapped.update(override)
|
||||
@@ -230,7 +229,7 @@ def _resolve_row_for_preview(
|
||||
"flags": all_flags,
|
||||
}
|
||||
|
||||
# auto_send gate (T6/OV-1)
|
||||
# auto_send gate
|
||||
if has_no_auto_send(resolved, mapping_meta):
|
||||
return {
|
||||
"resolved_status": "needs_mapping",
|
||||
@@ -261,8 +260,8 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
|
||||
return build_key(account_id, canon)
|
||||
|
||||
|
||||
# Campuri de continut editabile in preview (3.6). Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare) — vezi Non-Goals din PRD 3.6.
|
||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare).
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
||||
|
||||
|
||||
@@ -350,7 +349,7 @@ def apply_row_override(
|
||||
|
||||
|
||||
def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, dict]:
|
||||
"""Cauta cheile de idempotenta in submissions (batch, nu N+1 — Eng#5).
|
||||
"""Cauta cheile de idempotenta in submissions (batch, nu N+1).
|
||||
|
||||
Intoarce {idempotency_key: {id, id_prezentare, created_at}} pentru cheile gasite.
|
||||
"""
|
||||
@@ -371,7 +370,7 @@ def _already_sent_lookup(conn, account_id: int, keys: list[str]) -> dict[str, di
|
||||
"id_prezentare": r["id_prezentare"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
# Dual-lookup pentru chei legacy (OV-2: chei vechi cu account_id=None)
|
||||
# Dual-lookup pentru chei legacy (chei vechi cu account_id=None)
|
||||
legacy_keys_needed = [k for k in chunk if k not in found]
|
||||
if legacy_keys_needed:
|
||||
lph = ",".join("?" * len(legacy_keys_needed))
|
||||
@@ -403,8 +402,7 @@ async def upload_import(
|
||||
"""Upload fisier xlsx/csv -> staging in import_batches/import_rows.
|
||||
|
||||
Nu trimite nimic la RAR. Intoarce {import_id, columns, sample_rows, sheets?}.
|
||||
PII (raw_json) criptat Fernet la rest (Issue 5a).
|
||||
Scrieri bulk in tranzactie explicita (Issue 6).
|
||||
PII (raw_json) criptat Fernet la rest. Scrieri bulk in tranzactie explicita.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
data = await file.read()
|
||||
@@ -468,7 +466,7 @@ async def upload_import(
|
||||
try:
|
||||
sig = _signature(parsed.columns)
|
||||
|
||||
# Issue 6: tranzactie explicita BEGIN IMMEDIATE + executemany
|
||||
# Tranzactie explicita BEGIN IMMEDIATE + executemany
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Insert import_batches
|
||||
@@ -482,7 +480,7 @@ async def upload_import(
|
||||
# Insert import_rows bulk (executemany) cu PII criptat
|
||||
row_params = []
|
||||
for i, row_dict in enumerate(parsed.rows):
|
||||
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet (Issue 5a)
|
||||
raw_json_enc = encrypt_creds(row_dict) # Criptat Fernet
|
||||
row_params.append((batch_id, i, raw_json_enc, "pending", None))
|
||||
|
||||
conn.executemany(
|
||||
@@ -506,11 +504,8 @@ async def upload_import(
|
||||
# Sample rows (primele 3, fara PII)
|
||||
sample = parsed.rows[:3]
|
||||
|
||||
# Persistam metadata parsedata (coercion_flags, date_col_format, formula_columns)
|
||||
# in import_batches pentru refolosire la preview (stocam ca JSON in 'status' nu e OK,
|
||||
# ci ca metadate suplimentare — le stocam intr-un rand separat sau returnam direct)
|
||||
# Solutie: le returnam in raspuns; preview-ul le va recalcula din raw_json deja stocat
|
||||
# SAU le stocam ca un camp extra. Cel mai simplu: stocam coloanele in batch.
|
||||
# Metadata parsata (coercion_flags etc.) se intoarce in raspuns; preview-ul
|
||||
# o recalculeaza din raw_json deja stocat.
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET ok=?, needs_review=? WHERE id=?",
|
||||
(0, len(parsed.coercion_flags), batch_id),
|
||||
@@ -532,7 +527,7 @@ async def upload_import(
|
||||
result["column_mapping"] = json.loads(existing_mapping["json_mapare"])
|
||||
result["format_data"] = existing_mapping["format_data"]
|
||||
else:
|
||||
# Sugestii fuzzy per coloana (Issue 5b: refoloseste normalize_for_match)
|
||||
# Sugestii fuzzy per coloana
|
||||
suggestions: dict[str, list[dict]] = {}
|
||||
for col in parsed.columns:
|
||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||
@@ -676,7 +671,7 @@ def save_column_mapping(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /v1/import/{id}/preview — 6 stari per rand (T2 + T11) #
|
||||
# GET /v1/import/{id}/preview — 6 stari per rand #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/{import_id}/preview")
|
||||
@@ -686,8 +681,8 @@ def preview_import(
|
||||
) -> dict:
|
||||
"""Preview 6 stari per rand: ok/needs_mapping/needs_data/needs_review/already_sent/duplicate_in_file.
|
||||
|
||||
Nu enqueue-aza nimic. Already_sent = lookup batch (Eng#5). Duplicate_in_file = intra-batch
|
||||
collision (OV-3: EXCLUSIV aici, NU in reconcile.py/worker).
|
||||
Nu enqueue-aza nimic. Already_sent = lookup batch. Duplicate_in_file = intra-batch
|
||||
collision (EXCLUSIV aici, NU in reconcile.py/worker).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
@@ -708,7 +703,7 @@ def preview_import(
|
||||
if not raw_rows_db:
|
||||
return {"rows": [], "summary": {}}
|
||||
|
||||
# Decripteaza si reconstruieste randurile + override-urile editate (3.6)
|
||||
# Decripteaza si reconstruieste randurile + override-urile editate
|
||||
rows: list[dict] = []
|
||||
overrides: list[dict] = []
|
||||
for r in raw_rows_db:
|
||||
@@ -747,22 +742,18 @@ def preview_import(
|
||||
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
||||
format_data = mapping_row["format_data"]
|
||||
|
||||
# Incarca maparea de operatii o singura data (Eng#5: load_mapping o singura data)
|
||||
# Incarca maparea de operatii o singura data
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
# Reconstruieste parsed info (coercion_flags si date_col_format) din datele stocate
|
||||
# Nota: import_rows stocheaza raw_json dupa coercion (din parse_file)
|
||||
# Recalculam flags din valorile stocate (coercion_flags nu e stocat separat)
|
||||
# Vom folosi o detectie simpla: VIN-uri care par numerice si odometru float
|
||||
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
||||
# detectie simpla de VIN numeric.
|
||||
coercion_flags_map: dict[int, list[str]] = {}
|
||||
# Detectam din valorile stocate
|
||||
for i, row_dict in enumerate(rows):
|
||||
flags = []
|
||||
# Detectam VIN numeric: daca valoarea a fost stocata si arata ca numar
|
||||
for col_f, camp_c in json_mapare.items():
|
||||
if camp_c == "vin":
|
||||
vin_val = row_dict.get(col_f)
|
||||
@@ -830,11 +821,11 @@ def preview_import(
|
||||
"idempotency_key": key,
|
||||
})
|
||||
|
||||
# Already_sent: batch lookup (Eng#5 — nu N+1)
|
||||
# Already_sent: batch lookup (nu N+1)
|
||||
unique_keys = list(set(keys_for_lookup))
|
||||
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
||||
|
||||
# Duplicate_in_file (OV-3): detectie coliziuni intra-batch
|
||||
# Duplicate_in_file: detectie coliziuni intra-batch.
|
||||
# Grupam pe cheie de idempotenta: >1 rand cu aceeasi cheie = duplicate
|
||||
key_to_indices: dict[str, list[int]] = {}
|
||||
for row in preview_rows:
|
||||
@@ -857,7 +848,7 @@ def preview_import(
|
||||
row["already_sent_info"] = sent_info
|
||||
continue
|
||||
|
||||
# Duplicate_in_file (OV-3): >1 rand cu aceeasi cheie in ACELASI fisier
|
||||
# Duplicate_in_file: >1 rand cu aceeasi cheie in ACELASI fisier
|
||||
indices_with_same_key = key_to_indices.get(k, [])
|
||||
if len(indices_with_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||
others = [idx for idx in indices_with_same_key if idx != row["row_index"]]
|
||||
@@ -911,7 +902,7 @@ def preview_import(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare (T5+T12) #
|
||||
# POST /v1/import/{id}/commit — gate HARD + enqueue + log atestare #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class CommitIn(BaseModel):
|
||||
@@ -929,9 +920,9 @@ def commit_import(
|
||||
req: CommitIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare (T5+T12).
|
||||
"""Gate HARD confirmare + enqueue randuri ok + log atestare.
|
||||
|
||||
TOCTOU (Issue 1): INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
TOCTOU: INSERT per-rand cu ON CONFLICT(idempotency_key) DO NOTHING.
|
||||
Randuri colidante = reclasificate already_sent in rezultatul commit-ului.
|
||||
rows_hash + n_confirmed acopera DOAR randurile efectiv puse in coada.
|
||||
"""
|
||||
@@ -981,7 +972,7 @@ def commit_import(
|
||||
elif r["resolved_status"] == "needs_review":
|
||||
review_indices.add(r["row_index"])
|
||||
|
||||
# needs_review bifate explicit (Voce#1 — atestare pe valori)
|
||||
# needs_review bifate explicit (atestare pe valori)
|
||||
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
|
||||
for idx in confirmed_review:
|
||||
# Gaseste randul needs_review si il adauga la ok_rows
|
||||
@@ -1040,7 +1031,7 @@ def commit_import(
|
||||
# Incarca maparea de operatii
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# T2: validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
# Validare nomenclator + reguli text incarcate O DATA, inainte de bucla pe randuri.
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
@@ -1049,10 +1040,9 @@ def commit_import(
|
||||
toctou_collisions: list[int] = []
|
||||
rows_for_hash: list[str] = []
|
||||
|
||||
# Enqueue in tranzactie explicita (Issue 6)
|
||||
# Enqueue in tranzactie explicita
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# purge_after pentru submissions noi (T16)
|
||||
purge_after_sql = "datetime('now', '+90 days')"
|
||||
|
||||
for ok_row in ok_rows:
|
||||
@@ -1100,7 +1090,7 @@ def commit_import(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
|
||||
# Override editat in preview — aplicat ULTIMUL, ca in resolver.
|
||||
override = ok_row.get("override") or {}
|
||||
if override:
|
||||
mapped.update(override)
|
||||
@@ -1127,7 +1117,7 @@ def commit_import(
|
||||
|
||||
payload_json = json.dumps(mapped, ensure_ascii=False)
|
||||
|
||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU — Issue 1)
|
||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
||||
@@ -1140,7 +1130,6 @@ def commit_import(
|
||||
toctou_collisions.append(row_index)
|
||||
else:
|
||||
sub_id = cur.lastrowid
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, int(sub_id), resolved)
|
||||
enqueued.append({
|
||||
"submission_id": sub_id,
|
||||
@@ -1155,7 +1144,7 @@ def commit_import(
|
||||
|
||||
n_enqueued = len(enqueued)
|
||||
|
||||
# Log atestare (Voce#9): rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
||||
# Log atestare: rows_hash + n_confirmed acopera DOAR randurile puse in coada
|
||||
rows_hash = hashlib.sha256(
|
||||
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest() if rows_for_hash else ""
|
||||
@@ -1185,7 +1174,7 @@ def commit_import(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview (3.6) #
|
||||
# POST /v1/import/{id}/rand/{row_index}/editeaza — editare celule preview #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class RandEditIn(BaseModel):
|
||||
@@ -1205,7 +1194,7 @@ def editeaza_rand(
|
||||
req: RandEditIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Persista editarea unui rand de preview (mutatie pura — Approach B, 3.6).
|
||||
"""Persista editarea unui rand de preview (mutatie pura).
|
||||
|
||||
NU recalculeaza statusul si NU atinge `submissions`; preview-ul rederiva statusul
|
||||
prin `_resolve_row_for_preview` cu override aplicat ultimul.
|
||||
@@ -1225,7 +1214,7 @@ def editeaza_rand(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /v1/import/{id}/export-failed — CSV randuri esuate (T8) #
|
||||
# GET /v1/import/{id}/export-failed — CSV randuri esuate #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_EXPORT_FAILED_COLUMNS = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Router integrare US-001 — endpoint-uri de integrare externe.
|
||||
"""Router integrare — endpoint-uri de integrare externe.
|
||||
|
||||
Endpointuri:
|
||||
GET /v1/ping — readiness check per cont (autentificat sau dev fallback)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""API v1 — suprafata gateway (schelet).
|
||||
"""API v1 — suprafata gateway.
|
||||
|
||||
Endpointuri din plan.md sect. 4. In schelet:
|
||||
Endpointuri:
|
||||
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
||||
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
||||
- GET /v1/nomenclator: cache local.
|
||||
- GET /v1/mapari: listare mapari cont.
|
||||
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
|
||||
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -79,7 +77,7 @@ def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, tex
|
||||
|
||||
|
||||
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
||||
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest."""
|
||||
"""Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT)."""
|
||||
return [
|
||||
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
|
||||
for u in unmapped
|
||||
@@ -87,7 +85,7 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def _motiv_clasificare(cl: dict) -> str | None:
|
||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7).
|
||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
||||
|
||||
None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut
|
||||
(needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping).
|
||||
@@ -107,7 +105,7 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
||||
|
||||
|
||||
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
|
||||
"""SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7).
|
||||
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
||||
|
||||
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
||||
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None.
|
||||
@@ -141,42 +139,40 @@ def create_prezentari(
|
||||
) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
Validarea de continut (T3, app.validation) ruleaza inainte de enqueue:
|
||||
esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv
|
||||
(plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape).
|
||||
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
|
||||
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat ->
|
||||
422 din Pydantic (validare de shape).
|
||||
account_id vine din cheia API (resolve_account_id): cont real cu cheie,
|
||||
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
|
||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
|
||||
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
|
||||
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
|
||||
# rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc,
|
||||
# rest — niciodata in clar in DB/loguri. Optional: cand lipsesc,
|
||||
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
|
||||
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
|
||||
conn = get_connection()
|
||||
results: list[SubmissionResult] = []
|
||||
try:
|
||||
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
||||
# load_mapping_meta include auto_send per op (gate pentru coduri noi).
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
|
||||
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run, invariant 5.2).
|
||||
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||
for prez in req.prezentari:
|
||||
content = prez.model_dump()
|
||||
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||
canon = canonicalize_row(content)
|
||||
key = build_key(account_id, canon)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis)
|
||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||
content.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
@@ -187,7 +183,7 @@ def create_prezentari(
|
||||
(key,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
# US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
||||
# Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
|
||||
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
|
||||
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
|
||||
if existing["status"] == "error":
|
||||
@@ -205,17 +201,16 @@ def create_prezentari(
|
||||
cl["rar_error"], creds_enc, existing["id"]),
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc,
|
||||
# decizie #17) — ambele canale converg pe parola corectata.
|
||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
||||
# — ambele canale converg pe parola corectata.
|
||||
if req.rar_credentials is not None:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(encrypt_creds(req.rar_credentials.model_dump()), acct),
|
||||
)
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||
# Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea
|
||||
# cade pe needs_data/needs_mapping, expune motivul (nu doar status).
|
||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
||||
continue
|
||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||
@@ -234,7 +229,7 @@ def create_prezentari(
|
||||
)
|
||||
continue
|
||||
|
||||
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
|
||||
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
|
||||
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if cl["blocked_error"]:
|
||||
@@ -247,13 +242,12 @@ def create_prezentari(
|
||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
||||
)
|
||||
sub_id = int(cur.lastrowid)
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||
# Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||
results.append(_rezultat_enqueue(sub_id, cl))
|
||||
|
||||
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
|
||||
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
|
||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||
dist: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.reactivated:
|
||||
@@ -284,7 +278,7 @@ def valideaza_prezentari(
|
||||
|
||||
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).
|
||||
payload + aceeasi mapare de cont. rar_credentials ignorat complet.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
@@ -301,7 +295,7 @@ def valideaza_prezentari(
|
||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||
if res["blocked_error"]:
|
||||
res = {**res, "status": "error"}
|
||||
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
|
||||
# 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"]
|
||||
@@ -329,7 +323,7 @@ def list_prezentari(
|
||||
try:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||
# sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem.
|
||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||
cols = (
|
||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||
"created_at, updated_at, payload_json"
|
||||
@@ -357,13 +351,13 @@ def list_prezentari(
|
||||
conn.close()
|
||||
|
||||
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since.
|
||||
_PREZENTARE_FIELDS = frozenset({
|
||||
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
||||
"next_attempt_at", "created_at", "updated_at", "account_id",
|
||||
"batch_id", "row_index", "purge_after",
|
||||
# T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
||||
# rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
|
||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||
"rar_error",
|
||||
@@ -383,7 +377,7 @@ def get_prezentare(
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# Acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# sau nu exista deloc — nu confirmam existenta.
|
||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||
row_dict = dict(row)
|
||||
@@ -397,11 +391,11 @@ def delete_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Sterge o trimitere blocata a contului cheii API (US-010).
|
||||
"""Sterge o trimitere blocata a contului cheii API.
|
||||
|
||||
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
|
||||
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi
|
||||
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare).
|
||||
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
|
||||
own-account `sent`/`sending` -> 409 (conflict de stare).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -424,10 +418,10 @@ def repune_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Re-pune in coada o trimitere blocata a contului cheii API (US-010).
|
||||
"""Re-pune in coada o trimitere blocata a contului cheii API.
|
||||
|
||||
`error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de
|
||||
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending).
|
||||
`error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE
|
||||
(404 cross-account/inexistent, 409 sent/sending).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -478,8 +472,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
||||
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
||||
|
||||
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
||||
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
|
||||
schelet; b64_image NU intra in CSV.
|
||||
account_id IS NULL apartin contului 1. b64_image NU intra in CSV.
|
||||
"""
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
sql = (
|
||||
@@ -514,7 +507,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
|
||||
"submission_id": r["id"],
|
||||
"status": r["status"],
|
||||
"id_prezentare": r["id_prezentare"] or "",
|
||||
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
# NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
"account_id": account_or_default(r["account_id"]),
|
||||
"vin": p.get("vin") or "",
|
||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||
@@ -539,7 +532,7 @@ def audit_export(
|
||||
|
||||
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
||||
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
||||
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
||||
`purge_after`. b64_image nu se exporta.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -568,7 +561,7 @@ def get_mapari(
|
||||
"""Maparile operatie->cod ale contului curent.
|
||||
|
||||
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
||||
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
|
||||
efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400.
|
||||
"""
|
||||
if account_id is not None and account_id != key_account:
|
||||
raise HTTPException(
|
||||
@@ -635,7 +628,7 @@ def create_mapare(
|
||||
|
||||
|
||||
class RarCredsIn(BaseModel):
|
||||
"""Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||
"""Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc."""
|
||||
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=1, repr=False)
|
||||
@@ -646,7 +639,7 @@ def set_rar_creds(
|
||||
req: RarCredsIn,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Seteaza creds RAR durabile per-cont (D4/T1).
|
||||
"""Seteaza creds RAR durabile per-cont.
|
||||
|
||||
Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback
|
||||
cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker).
|
||||
|
||||
@@ -112,7 +112,7 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None
|
||||
|
||||
|
||||
def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None:
|
||||
"""Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia.
|
||||
"""Eveniment de jurnal pentru un esec de auth: IP + prefix cheie, NU cheia.
|
||||
|
||||
Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time
|
||||
(observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte.
|
||||
@@ -142,7 +142,7 @@ def resolve_account_id(
|
||||
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
|
||||
- fara cheie + flag off -> cont implicit (id=1), back-compat
|
||||
- fara cheie + flag on -> 401
|
||||
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004).
|
||||
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie.
|
||||
"""
|
||||
settings = get_settings()
|
||||
plaintext = _extract_key(x_api_key, authorization)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
|
||||
|
||||
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO
|
||||
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul
|
||||
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test.
|
||||
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO.
|
||||
Helper-ul `load_test_credentials` citeste blocul <test> din settings.xml DOAR
|
||||
pentru dev local / probe pe mediul de test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,22 +22,21 @@ class Settings(BaseSettings):
|
||||
# --- Bază de date ---
|
||||
db_path: Path = ROOT / "data" / "autopass.db"
|
||||
|
||||
# --- Observabilitate / jurnal aplicatie (PRD 5.6) ---
|
||||
# --- Observabilitate / jurnal aplicatie ---
|
||||
# Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul
|
||||
# e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL.
|
||||
log_level: str = "INFO"
|
||||
# Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5).
|
||||
log_retention_days: int = 90
|
||||
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie, decizie §5).
|
||||
# Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie).
|
||||
# Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe.
|
||||
log_dir: Path = ROOT / ".run"
|
||||
log_file_max_bytes: int = 5_000_000
|
||||
log_file_backup_count: int = 5
|
||||
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
||||
# ale `sent` — un blocat n-are valoare de audit (decizie §5).
|
||||
# ale `sent` — un blocat n-are valoare de audit.
|
||||
blocked_retention_days: int = 30
|
||||
|
||||
# --- Securitate (CORE) ---
|
||||
# --- Securitate ---
|
||||
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
||||
# cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA
|
||||
# dar invalida da 401 indiferent de flag.
|
||||
@@ -49,29 +48,28 @@ class Settings(BaseSettings):
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
creds_key: str | None = None
|
||||
|
||||
# --- Sesiuni web (US-002, PRD 3.3) ---
|
||||
# --- Sesiuni web ---
|
||||
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
||||
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
||||
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
session_secret: str | None = None
|
||||
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
|
||||
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5),
|
||||
# CSRF enforce. Pentru dev rapid pe contul implicit id=1,
|
||||
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
|
||||
web_auth_required: bool = True
|
||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
|
||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag.
|
||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||
session_https_only: bool = False
|
||||
|
||||
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
|
||||
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
|
||||
# follow-up cand exista SMTP real configurat in .env.
|
||||
# --- Notificare email admin la signup ---
|
||||
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
|
||||
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
|
||||
# --- Rate-limit signup + login ---
|
||||
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
||||
signup_rate_max: int = 5
|
||||
signup_rate_window_s: int = 3600
|
||||
@@ -83,25 +81,23 @@ class Settings(BaseSettings):
|
||||
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
||||
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
|
||||
|
||||
# WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi
|
||||
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
|
||||
# WAF-ul RAR da 403 fara User-Agent de browser. Toate apelurile httpx il trimit.
|
||||
http_user_agent: str = "Mozilla/5.0"
|
||||
http_timeout_s: float = 30.0
|
||||
|
||||
# --- Worker ---
|
||||
worker_poll_interval_s: float = 5.0
|
||||
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
|
||||
# In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit
|
||||
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2.
|
||||
# Send DEZACTIVAT implicit (nu trimite la RAR). Activeaza-l explicit pentru
|
||||
# proba end-to-end.
|
||||
worker_send_enabled: bool = False
|
||||
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
|
||||
# creds vin per-cerere de la ROAAUTO (T2) — lasa False.
|
||||
# creds vin per-cerere de la ROAAUTO — lasa False.
|
||||
worker_use_test_creds: bool = False
|
||||
# T2 — recuperare orfane + retry/backoff:
|
||||
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
|
||||
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
|
||||
worker_retry_max_s: int = 300
|
||||
worker_max_retries: int = 8 # peste atat -> error + banner (pana persistenta)
|
||||
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||
|
||||
@property
|
||||
def rar_base_url(self) -> str:
|
||||
|
||||
14
app/db.py
14
app/db.py
@@ -61,13 +61,13 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if "rar_creds_enc" not in acc_cols:
|
||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||
if "active" not in acc_cols:
|
||||
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
|
||||
# Conturi existente raman active (default 1).
|
||||
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'.
|
||||
# Stare de ciclu de viata. 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'))"
|
||||
@@ -97,9 +97,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if "email_verified" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# Coloana import_rows.override_json (3.6, Approach B): patch canonic editat in
|
||||
# preview, criptat Fernet. Defensiv idempotent (ca is_admin in 3.3b) — DB create
|
||||
# inainte de 3.6 nu au coloana.
|
||||
# Coloana import_rows.override_json: patch canonic editat in preview, criptat Fernet.
|
||||
irows_tbl = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_rows'"
|
||||
).fetchone()
|
||||
@@ -151,7 +149,7 @@ def queue_depth(conn: sqlite3.Connection) -> int:
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
# --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) ---
|
||||
# --- Jurnal de aplicatie (app_events) ---
|
||||
|
||||
def insert_app_event(
|
||||
conn: sqlite3.Connection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
|
||||
"""Helper notificare email admin la signup.
|
||||
|
||||
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
|
||||
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Catalog central de erori AutoPass (PRD 5.4).
|
||||
"""Catalog central de erori AutoPass.
|
||||
|
||||
Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix),
|
||||
cu un helper care construieste obiectul de eroare pe 3 niveluri:
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
"""Cheie de idempotenta = hash de continut canonic.
|
||||
|
||||
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra
|
||||
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii.
|
||||
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra.
|
||||
Hash stabil peste o reprezentare canonica a prezentarii.
|
||||
|
||||
Treapta 2 (T9 + OV-2): extrage canonicalize_row + build_key ca helpere publice
|
||||
partajate intre canalul API si canalul import.
|
||||
canonicalize_row + build_key sunt helpere publice partajate intre canalul API si
|
||||
canalul import:
|
||||
- canonicalize_row: normeaza VIN/nr/odometru (strip ".0" Excel coercion) INAINTE
|
||||
de validare (§3.4bis) si INAINTE de cheie.
|
||||
de validare si INAINTE de cheie.
|
||||
- build_key: aplica account_or_default INAINTE de hash (None si 1 => o cheie).
|
||||
Altfel acelasi rand logic din canale diferite (account_id None pe canalul API,
|
||||
1 pe import) ar primi chei diferite -> al doilea FINALIZATA duplicat.
|
||||
|
||||
OV-2 — skew account_id: routerul vechi pasa account_id AS-PASSED (None pe canal API
|
||||
fara auth). Randurile se stocau sub account_or_default=1, dar cheia includea None.
|
||||
Acelasi rand logic din import (account_id=1) dadea cheie diferita -> already_sent
|
||||
rata -> al doilea FINALIZATA. Fix: build_key normalizeaza INTOTDEAUNA la
|
||||
account_or_default inainte de hash.
|
||||
|
||||
Migrare DB productie (OV-2): randurile existente cu cheie-None nu mai sunt gasite de
|
||||
build_key nou. Strategie documentata: dual-lookup la already_sent (incearca cheia
|
||||
noua, apoi cheia legacy). In dev nu exista date reale; la first-deploy productie
|
||||
se poate face recompute-keys o singura data.
|
||||
Randuri vechi cu cheie-None nu sunt gasite de build_key nou: dual-lookup la
|
||||
already_sent (cheia noua, apoi build_key_legacy) sau recompute-keys o singura data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,10 +40,7 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
- data_prestatie: strip (normalizarea la YYYY-MM-DD se face in parser).
|
||||
- prestatii: pastrate ca-atare (rezolvarea e in resolve_prestatii).
|
||||
"""
|
||||
# VIN
|
||||
vin = (raw.get("vin") or "").strip().upper()
|
||||
|
||||
# Nr. inmatriculare
|
||||
nr = (raw.get("nr_inmatriculare") or "").strip().upper()
|
||||
|
||||
# Odometru: strip ".0" Excel float coercion
|
||||
@@ -82,8 +73,8 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
||||
"""SHA-256 partajat canal-API + canal-import.
|
||||
|
||||
Aplica account_or_default inainte de hash (OV-2): None si 1 colapseaza la
|
||||
aceeasi cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||
"""
|
||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||
from .mapping import account_or_default
|
||||
@@ -106,8 +97,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||
|
||||
NOTA: dupa OV-2, account_id=None si account_id=1 produc ACEEASI cheie
|
||||
(via account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
||||
NOTA: account_id=None si account_id=1 produc ACEEASI cheie (via
|
||||
account_or_default in build_key). Randuri vechi cu cheie-None nu sunt
|
||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||
"""
|
||||
canon = canonicalize_row(prezentare)
|
||||
@@ -117,8 +108,8 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||
"""Cheia in formatul vechi (account_id AS-PASSED, fara canonicalize).
|
||||
|
||||
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi
|
||||
(dinainte de T9). Nu folosi pentru randuri noi.
|
||||
Folosita EXCLUSIV pentru dual-lookup la already_sent pe DB cu randuri vechi.
|
||||
Nu folosi pentru randuri noi.
|
||||
"""
|
||||
canonic = {
|
||||
"account_id": account_id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2, U1).
|
||||
"""Parser fisiere xlsx/csv pentru import prezentari (Treapta 2).
|
||||
|
||||
Arhitectura 2-treceri (Issue 2, consens cross-model):
|
||||
Arhitectura 2-treceri:
|
||||
Trecerea 1 — read_only=True: dim-check (FileTooLarge) + detectie multi-sheet.
|
||||
Trecerea 2 — normal-mode: header + merged cells + body.
|
||||
Aceasta separare e necesara deoarece openpyxl read_only=True nu vede celule imbinate.
|
||||
@@ -29,7 +29,7 @@ from typing import Any, NamedTuple
|
||||
MAX_ROWS = 5_000
|
||||
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate (Issue 3)
|
||||
# Prag rata None pe o coloana obligatorie -> mesaj formule necalculate
|
||||
FORMULA_NONE_RATE = 0.6
|
||||
|
||||
# Coloane cheie pentru detectia footer-ului (trim structural)
|
||||
@@ -82,7 +82,7 @@ class ParsedFile(NamedTuple):
|
||||
columns: list[str] # Numele coloanelor detectate (din header)
|
||||
rows: list[dict[str, Any]] # Fiecare rand: {coloana: valoare_bruta}
|
||||
coercion_flags: dict[int, list[str]] # {row_index: [motive needs_review]}
|
||||
formula_columns: list[str] # Coloane cu rata None ridicata (Issue 3)
|
||||
formula_columns: list[str] # Coloane cu rata None ridicata
|
||||
date_col_format: dict[str, str] # {coloana: "DD.MM.YYYY" | "YYYY-MM-DD" | "native" | "ambiguous"}
|
||||
|
||||
|
||||
@@ -230,13 +230,13 @@ def _xlsx_parse_sheet(ws, sheet_name: str) -> ParsedFile:
|
||||
# Trim footer: elimina randuri trailing unde coloanele cheie sunt goale
|
||||
raw_rows = _trim_footer(raw_rows, col_names)
|
||||
|
||||
# Detectie coloane cu formule (rata None, Issue 3)
|
||||
# Detectie coloane cu formule (rata None ridicata)
|
||||
formula_columns = _detect_formula_columns(col_values, len(raw_rows))
|
||||
|
||||
# Detectie format data la nivel de coloana (T10/OV-8)
|
||||
# Detectie format data la nivel de coloana
|
||||
date_col_format = _detect_date_formats(col_values, col_names)
|
||||
|
||||
# Coercion + flags needs_review (T3)
|
||||
# Coercion + flags needs_review
|
||||
coercion_flags: dict[int, list[str]] = {}
|
||||
processed_rows: list[dict[str, Any]] = []
|
||||
for i, row_dict in enumerate(raw_rows):
|
||||
@@ -289,7 +289,7 @@ def _trim_footer(rows: list[dict[str, Any]], col_names: list[str]) -> list[dict[
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Detectie coloane formule (Issue 3) #
|
||||
# Detectie coloane formule #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> list[str]:
|
||||
@@ -306,7 +306,7 @@ def _detect_formula_columns(col_values: dict[str, list[Any]], n_rows: int) -> li
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dezambiguizare data la nivel de coloana (T10 / OV-8) #
|
||||
# Dezambiguizare data la nivel de coloana #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str]) -> dict[str, str]:
|
||||
@@ -344,7 +344,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
|
||||
result[col_name] = "mixed"
|
||||
continue
|
||||
|
||||
# Toate string — detectie format la nivel de coloana (OV-8)
|
||||
# Toate string — detectie format la nivel de coloana
|
||||
fmt = _infer_date_format_from_column(str_vals)
|
||||
result[col_name] = fmt
|
||||
|
||||
@@ -354,7 +354,7 @@ def _detect_date_formats(col_values: dict[str, list[Any]], col_names: list[str])
|
||||
def _infer_date_format_from_column(str_vals: list[str]) -> str:
|
||||
"""Detecteaza formatul datei dintr-o lista de valori string.
|
||||
|
||||
Logica OV-8: daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
|
||||
Daca ORICARE rand are token pozitia-1 > 12 -> coloana e DD-first.
|
||||
Daca toti zi <= 12 -> ambiguu.
|
||||
"""
|
||||
dd_first_evidence = False
|
||||
@@ -421,7 +421,7 @@ def _split_date(s: str) -> list[str] | None:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Coercion per rand (T3) #
|
||||
# Coercion per rand #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _coerce_row(row_dict: dict[str, Any], col_names: list[str]) -> tuple[dict[str, Any], list[str]]:
|
||||
@@ -682,7 +682,7 @@ def parse_csv(data: bytes) -> ParsedFile:
|
||||
def parse_xlsx(data: bytes, *, sheet_name: str | None = None) -> ParsedFile:
|
||||
"""Parseaza un fisier XLSX.
|
||||
|
||||
Arhitectura 2-treceri (Issue 2):
|
||||
Arhitectura 2-treceri:
|
||||
1. read_only=True: dim-check + detectie multi-sheet
|
||||
2. normal-mode: header + merged cells + body
|
||||
|
||||
|
||||
19
app/main.py
19
app/main.py
@@ -1,9 +1,7 @@
|
||||
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
|
||||
|
||||
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
|
||||
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
|
||||
|
||||
Pornire dev: uvicorn app.main:app --reload
|
||||
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici:
|
||||
un worker mort nu trebuie sa lase containerul "sanatos".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,7 +42,7 @@ from .web.session import AdminRequired, LoginRequired
|
||||
async def lifespan(app: FastAPI):
|
||||
install_log_redaction()
|
||||
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
||||
# in loc de 500 brut la primul POST /v1/prezentari (cazul reprodus din VFP).
|
||||
# in loc de 500 brut la primul POST /v1/prezentari.
|
||||
validate_creds_key()
|
||||
init_db()
|
||||
yield
|
||||
@@ -61,7 +59,7 @@ app.add_middleware(
|
||||
https_only=settings.session_https_only,
|
||||
same_site="strict",
|
||||
)
|
||||
# US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
|
||||
# request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza
|
||||
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
||||
# inclusiv 401/404/422/500 produse mai in interior.
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
@@ -97,13 +95,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut.
|
||||
"""Orice excepție neprinsa -> 500 STRUCTURAT din catalog in loc de 500 brut.
|
||||
|
||||
Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message)
|
||||
+ `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul
|
||||
complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text).
|
||||
Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException)
|
||||
raman neatinse — acesta prinde doar ce nu are handler dedicat.
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
||||
try:
|
||||
@@ -144,9 +140,8 @@ app.include_router(admin_router)
|
||||
def healthz() -> dict:
|
||||
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
|
||||
|
||||
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
|
||||
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
|
||||
orchestratorul decide pe campul `worker_alive`.
|
||||
Intoarce 200 mereu cu detalii; orchestratorul decide restartul pe campul
|
||||
`worker_alive`.
|
||||
"""
|
||||
settings = get_settings()
|
||||
conn = get_connection()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Mapare operatie ROAAUTO -> cod prestatie RAR + fuzzy lookup pentru editor.
|
||||
|
||||
Contract (varianta hibrida, decis 2026-06-15): un item de prestatie poate veni
|
||||
fie cu `cod_prestatie` (cod RAR direct, ca pana acum), fie cu `cod_op_service`
|
||||
Contract (varianta hibrida): un item de prestatie poate veni
|
||||
fie cu `cod_prestatie` (cod RAR direct), fie cu `cod_op_service`
|
||||
(cod intern ROAAUTO) + `denumire`. La ingestie incercam sa rezolvam codul intern
|
||||
prin `operations_mapping`; daca nu exista mapare -> submission `needs_mapping`
|
||||
(nu se trimite la RAR), iar operatia apare in editorul web unde userul o mapeaza
|
||||
@@ -87,7 +87,7 @@ def suggest_codes(
|
||||
]
|
||||
|
||||
|
||||
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text (US-010).
|
||||
# Prefixul pus pe `cod_sursa` cand un item e rezolvat printr-o regula text.
|
||||
# Forma: "text_rule:<pattern original al regulii castigatoare>". Payload-harmless —
|
||||
# RAR citeste doar `cod_prestatie`; `cod_sursa` ramane in payload_json fara efect.
|
||||
COD_SURSA_TEXT_RULE_PREFIX = "text_rule:"
|
||||
@@ -111,10 +111,10 @@ def _rezolva_din_reguli_text(
|
||||
`valid_codes` (cand `valid_codes` e setat), nu intoarcem un cod invalid ->
|
||||
(None, None, None) (operatia ramane nemapata), coerent cu garda din `resolve_prestatii`.
|
||||
|
||||
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie US-010), nu cel
|
||||
Pattern-ul intors e cel ORIGINAL al regulii (pentru telemetrie), nu cel
|
||||
normalizat folosit la match. `auto_send` = flagul regulii castigatoare: cand e
|
||||
falsy (DEFAULT 0, decizia CEO de siguranta) randul trebuie TINUT pentru verificare
|
||||
umana, nu trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
|
||||
falsy (DEFAULT 0, de siguranta) randul trebuie TINUT pentru verificare umana, nu
|
||||
trimis automat la RAR (blast radius substring + FINALIZATA ireversibil).
|
||||
"""
|
||||
if not text_rules:
|
||||
return None, None, None
|
||||
@@ -136,7 +136,7 @@ def _rezolva_din_reguli_text(
|
||||
|
||||
|
||||
def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
|
||||
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text (US-010).
|
||||
"""Extrage din itemii rezolvati cei care au primit cod dintr-o regula text.
|
||||
|
||||
Intoarce [{pattern, cod_prestatie}] pentru fiecare item al carui `cod_sursa`
|
||||
incepe cu `COD_SURSA_TEXT_RULE_PREFIX`. Pur (fara DB); apelantii cu `conn` il
|
||||
@@ -154,7 +154,7 @@ def text_rule_hits(resolved: list[dict] | None) -> list[dict]:
|
||||
|
||||
|
||||
def text_rules_overlap(pattern: str, existing_rules: list[dict] | None) -> list[dict]:
|
||||
"""Reguli text existente care se SUPRAPUN cu `pattern` (US-011, avertisment neblocant).
|
||||
"""Reguli text existente care se SUPRAPUN cu `pattern` (avertisment neblocant).
|
||||
|
||||
Overlap = pattern-ul nou normalizat (`normalize_for_match`) e substring al unei
|
||||
reguli existente SAU invers (oricare directie). Pur, determinist, fara DB.
|
||||
@@ -192,9 +192,9 @@ def resolve_prestatii(
|
||||
- item fara cod, fara mapare si fara regula text -> ramane nemapat.
|
||||
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
|
||||
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
|
||||
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
|
||||
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
|
||||
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
|
||||
fluxul needs_mapping. RAR accepta NUMAI coduri din nomenclator (coloana
|
||||
COD_PRESTATIE max 5 car.); un cod necunoscut da HTTP 500 si RECORD PARTIAL
|
||||
la RAR (terminal) -> nu-l trimitem niciodata raw.
|
||||
|
||||
Precedenta (stricta): `cod_prestatie` direct valid > mapare exacta `cod_op_service`
|
||||
in `mapping` > reguli text > nemapat. Regulile text se incearca DOAR cand nu exista
|
||||
@@ -217,8 +217,8 @@ def resolve_prestatii(
|
||||
unmapped: list[dict] = []
|
||||
for item in prestatii or []:
|
||||
it = dict(item)
|
||||
# Curata adnotarile aditive ale rezolvarii (cod_sursa US-010 + flagul de
|
||||
# hold pe regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
|
||||
# Curata adnotarile aditive ale rezolvarii (cod_sursa + flagul de hold pe
|
||||
# regula auto_send=0): se recalculeaza de la zero la fiecare rezolvare.
|
||||
# Altfel, un item re-rezolvat acum prin alta cale (ex. mapare exacta) ar pastra
|
||||
# un cod_sursa/flag stale din payload -> telemetrie falsa + hold gresit.
|
||||
it.pop("cod_sursa", None)
|
||||
@@ -246,11 +246,11 @@ def resolve_prestatii(
|
||||
)
|
||||
if cod_regula is not None:
|
||||
it["cod_prestatie"] = cod_regula
|
||||
# Adnotare aditiva (US-010): marcheaza ca rezolvat-prin-regula cu
|
||||
# pattern-ul sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
||||
# Adnotare aditiva: marcheaza ca rezolvat-prin-regula cu pattern-ul
|
||||
# sursa. Payload-harmless (RAR citeste doar cod_prestatie).
|
||||
it["cod_sursa"] = f"{COD_SURSA_TEXT_RULE_PREFIX}{pattern_regula or ''}"
|
||||
# Siguranta CEO (US-001): regula cu auto_send=0 rezolva codul dar
|
||||
# TINE randul pentru verificare umana (has_no_auto_send -> True).
|
||||
# Siguranta: regula cu auto_send=0 rezolva codul dar TINE randul
|
||||
# pentru verificare umana (has_no_auto_send -> True).
|
||||
if not auto_send_regula:
|
||||
it["regula_fara_autosend"] = True
|
||||
else:
|
||||
@@ -273,7 +273,7 @@ def account_or_default(account_id: int | None) -> int:
|
||||
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
||||
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
||||
|
||||
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
|
||||
Aplica regula: NULL apartine contului 1 (legacy).
|
||||
Foloseste DOAR pe submissions (account_id NULLABLE).
|
||||
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
||||
"""
|
||||
@@ -356,7 +356,7 @@ def load_mapping(conn, account_id: int | None) -> dict[str, str]:
|
||||
def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
|
||||
"""{cod_op_service -> {cod_prestatie, auto_send}} pentru un cont.
|
||||
|
||||
T6/OV-1: varianta extinsa care include si flagul auto_send per operatie.
|
||||
Varianta extinsa care include si flagul auto_send per operatie.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
@@ -379,7 +379,7 @@ def classify_prezentare(
|
||||
"""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).
|
||||
a garanta acelasi verdict — invariantul de corectitudine dry-run.
|
||||
|
||||
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
|
||||
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
|
||||
@@ -434,10 +434,10 @@ def classify_prezentare(
|
||||
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
|
||||
"""Verifica daca vreun item rezolvat are auto_send=0 (mapare exacta SAU regula text).
|
||||
|
||||
T6/OV-1: un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
|
||||
PRD 5.8 US-001 (decizia CEO): la fel pentru un item rezolvat printr-o REGULA TEXT cu
|
||||
auto_send=0 — marcat de `resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri
|
||||
randul ramane needs_mapping (review manual) pana cand operatorul activeaza „In coada".
|
||||
Un cod nou-mapat (operations_mapping) cu auto_send=0 nu trebuie trimis automat.
|
||||
La fel pentru un item rezolvat printr-o REGULA TEXT cu auto_send=0 — marcat de
|
||||
`resolve_prestatii` cu `regula_fara_autosend`. In ambele cazuri randul ramane
|
||||
needs_mapping (review manual) pana cand operatorul activeaza „In coada".
|
||||
Items cu cod_prestatie direct (nu via cod_op_service/regula) nu sunt afectate.
|
||||
"""
|
||||
for item in resolved:
|
||||
@@ -457,7 +457,7 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
||||
|
||||
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
|
||||
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
|
||||
apartine contului 1). Filtrarea in SQL, nu post-hoc in Python.
|
||||
"""
|
||||
nomenclator = load_nomenclator(conn)
|
||||
if account_id is not None:
|
||||
@@ -577,9 +577,9 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
|
||||
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
||||
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||
|
||||
US-010: telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event
|
||||
inghite exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} —
|
||||
fara PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
|
||||
Telemetrie „ce regula a rezolvat ce submission". Best-effort (log_event inghite
|
||||
exceptiile). Context = {submission_id, account_id, pattern, cod_prestatie} — fara
|
||||
PII (pattern + cod nu sunt PII). Import local: evita orice risc de ciclu la import.
|
||||
"""
|
||||
hits = text_rule_hits(resolved)
|
||||
if not hits:
|
||||
@@ -604,25 +604,25 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
"""Re-rezolva submission-urile `needs_mapping` ale unui cont dupa o noua mapare.
|
||||
|
||||
Pentru fiecare: aplica maparea curenta; daca nu mai raman op-uri nemapate ->
|
||||
ruleaza validarea de continut (T3) si trece pe `queued` (sau `needs_data` cu
|
||||
ruleaza validarea de continut si trece pe `queued` (sau `needs_data` cu
|
||||
motiv), resetand backoff-ul. Daca raman nemapate, ramane `needs_mapping` cu
|
||||
motivul actualizat. Intoarce {requeued, still_blocked, needs_data, review_manual}.
|
||||
|
||||
T6/OV-1: auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane
|
||||
'needs_mapping' cu motiv "review manual"); previne FINALIZATA eronat permanent.
|
||||
auto_send=0 pe un cod nou-mapat -> nu trece pe 'queued' (ramane 'needs_mapping'
|
||||
cu motiv "review manual"); previne FINALIZATA eronat permanent.
|
||||
|
||||
T7: batch_id != None -> scope la seria comitata (NU cross-batch).
|
||||
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
|
||||
batch_id != None -> scope la seria comitata (NU cross-batch).
|
||||
batch_id is None -> re-rezolva toti (canal API, batch_id IS NULL inclus).
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
mapping_meta = load_mapping_meta(conn, acct)
|
||||
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
# T2: incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||
# Incarca regulile text O DATA, inainte de bucla pe randuri.
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
if batch_id is not None:
|
||||
# T7: scope la batch-ul specificat (import commit explicit).
|
||||
# Scope la batch-ul specificat (import commit explicit).
|
||||
# NU atinge randuri din alte batches sau din feed API.
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions "
|
||||
@@ -631,8 +631,8 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
).fetchall()
|
||||
else:
|
||||
# POST /v1/mapari (save manual): re-rezolva EXCLUSIV canalul API (batch_id IS NULL).
|
||||
# T7/R1 INCHIS: salvarea unei mapari NU re-queues randuri din batches de import
|
||||
# (cross-batch / cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
|
||||
# Salvarea unei mapari NU re-queues randuri din batches de import (cross-batch /
|
||||
# cross-feed). Batches de import sunt re-rezolvate doar la commit explicit.
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM submissions "
|
||||
"WHERE status='needs_mapping' AND account_id=? AND batch_id IS NULL",
|
||||
@@ -649,7 +649,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
content["prestatii"] = resolved
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
# US-010: telemetrie pentru itemii rezolvati prin regula text.
|
||||
# Telemetrie pentru itemii rezolvati prin regula text.
|
||||
_emite_text_rule_hits(conn, acct, r["id"], resolved)
|
||||
|
||||
if unmapped:
|
||||
@@ -660,7 +660,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
|
||||
stats["still_blocked"] += 1
|
||||
continue
|
||||
|
||||
# T6/OV-1: verifica auto_send inainte de re-queuing
|
||||
# Verifica auto_send inainte de re-queuing.
|
||||
if has_no_auto_send(resolved, mapping_meta):
|
||||
conn.execute(
|
||||
"UPDATE submissions SET payload_json=?, rar_error=?, updated_at=datetime('now') WHERE id=?",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Modele Pydantic pentru suprafata API.
|
||||
|
||||
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare,
|
||||
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial
|
||||
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
|
||||
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
|
||||
Aici sunt doar formele de baza + normalizare strip/upper. Validarea completa de
|
||||
continut (regex VIN, interval data, R-ODO/I-ODO -> odometruInitial, ordine
|
||||
odometru) este in app.validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +19,7 @@ class RarCredentials(BaseModel):
|
||||
|
||||
|
||||
class PrestatieItem(BaseModel):
|
||||
"""O operatie de declarat. Contract hibrid (decis 2026-06-15):
|
||||
"""O operatie de declarat. Contract hibrid:
|
||||
|
||||
ROAAUTO poate trimite FIE `cod_prestatie` (cod RAR direct, ex. OE-1), FIE
|
||||
`cod_op_service` (cod intern ROAAUTO) + `denumire` — pe care gateway-ul le
|
||||
@@ -55,7 +54,7 @@ class PrezentareIn(BaseModel):
|
||||
Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de
|
||||
continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in
|
||||
app.validation.validate_prezentare si NU resping cererea — marcheaza
|
||||
`needs_data` (plan.md sect. 3).
|
||||
`needs_data`.
|
||||
"""
|
||||
|
||||
vin: str
|
||||
@@ -102,12 +101,12 @@ class SubmissionResult(BaseModel):
|
||||
status: str
|
||||
id_prezentare: int | None = None
|
||||
deduped: bool = False # True daca idempotency a intors un submission existent
|
||||
# US-012 (decizie /autoplan #19): camp ADITIV. True cand un rand `error` cu aceeasi
|
||||
# cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit.
|
||||
# `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||
# Camp aditiv. True cand un rand `error` cu aceeasi cheie de continut a fost
|
||||
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
||||
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||
reactivated: bool = False
|
||||
# Raspuns ONEST pentru randuri blocate (PRD 5.7): orice status != 'queued' isi
|
||||
# expune motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
||||
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
||||
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
||||
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
||||
# Pe ramura on_unmapped_error='error' pastreaza COD_NEMAPAT (compat).
|
||||
# nemapate = coduri fara mapare RAR (needs_mapping / respins), 3 niveluri + cod_op_service/denumire.
|
||||
@@ -122,7 +121,7 @@ class PrezentariResponse(BaseModel):
|
||||
|
||||
|
||||
class ValidarePrezentariRequest(BaseModel):
|
||||
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2)."""
|
||||
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue."""
|
||||
|
||||
rar_credentials: RarCredentials | None = None
|
||||
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Logger structurat central (PRD 5.6 US-003).
|
||||
"""Logger structurat central.
|
||||
|
||||
Singurul punct prin care se emit evenimente de aplicatie: garanteaza format,
|
||||
redactare si dublul canal (app_events in DB + log text rotativ) consistente si
|
||||
imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU
|
||||
doboara cererea/worker-ul.
|
||||
imposibil de ocolit. Best-effort: o cadere a jurnalului NU doboara cererea/worker-ul.
|
||||
|
||||
Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii`
|
||||
(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007).
|
||||
(creds/token mascate integral, VIN/nr partial) inainte de persistare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,8 +21,8 @@ from .config import get_settings
|
||||
from .db import get_connection, insert_app_event
|
||||
from .security import redact_pii, scrub_text
|
||||
|
||||
# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil
|
||||
# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii.
|
||||
# request_id al cererii curente. Setat de middleware-ul HTTP; disponibil in
|
||||
# handlerul de erori si aici, fara a polua semnaturile de functii.
|
||||
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"request_id", default=None
|
||||
)
|
||||
@@ -31,7 +30,7 @@ request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50}
|
||||
|
||||
# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default);
|
||||
# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces).
|
||||
# worker-ul cheama set_source('worker') la pornire (fisier per-proces).
|
||||
_DEFAULT_SOURCE = "api"
|
||||
|
||||
_loggers: dict[str, logging.Logger] = {}
|
||||
@@ -46,9 +45,9 @@ def set_source(sursa: str) -> None:
|
||||
def _text_logger(sursa: str) -> logging.Logger:
|
||||
"""Logger cu RotatingFileHandler pe fisier per-proces (app-<sursa>.log).
|
||||
|
||||
Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy.
|
||||
Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger
|
||||
nou, fara a acumula handlere duplicate pe acelasi fisier.
|
||||
Rotatia pe dimensiune e in aplicatie — nu depindem de deploy. Cheia de cache
|
||||
include calea: la schimbarea log_dir (teste) se creeaza un logger nou, fara a
|
||||
acumula handlere duplicate pe acelasi fisier.
|
||||
"""
|
||||
settings = get_settings()
|
||||
path = settings.log_dir / f"app-{sursa}.log"
|
||||
@@ -94,10 +93,10 @@ def log_event(
|
||||
) -> None:
|
||||
"""Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat).
|
||||
|
||||
- `tip`: text liber documentat (lista extensibila, decizie §5).
|
||||
- `tip`: text liber documentat (lista extensibila).
|
||||
- `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat.
|
||||
- `context`: metadate (submission_id, count, status...) — NU payload PII integral.
|
||||
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4);
|
||||
- `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL);
|
||||
None -> deschide/inchide o conexiune proprie.
|
||||
Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul).
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
|
||||
"""Extragere payload submission -> campuri afisabile.
|
||||
|
||||
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
||||
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
||||
@@ -115,6 +115,16 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
|
||||
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
|
||||
|
||||
# Operatia de service originala (codul intern + denumire venita prin API/import),
|
||||
# distincta de operatia RAR mapata (cod_rar).
|
||||
# Conventie goala: aceste campuri intorc "" (string gol) cand lipsesc — NU EMPTY="—".
|
||||
# Motivul: apelantul decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
|
||||
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
|
||||
op_service_cod = _clean_str(item.get("cod_op_service"))
|
||||
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
|
||||
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
|
||||
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
|
||||
|
||||
return {
|
||||
"vehicul_nr": nr or EMPTY,
|
||||
"vin": vin or EMPTY,
|
||||
@@ -124,4 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
"odometru": odo or EMPTY,
|
||||
"cod": cod or EMPTY,
|
||||
"cod_rar": cod_rar or EMPTY,
|
||||
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
||||
"op_service_cod": op_service_cod,
|
||||
"op_service_denumire": op_service_denumire,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ SENSITIVE_KEYS = frozenset(
|
||||
|
||||
|
||||
# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR
|
||||
# partial (ultimele 4), niciodata integral (PRD 5.6 US-007, L.142/GDPR).
|
||||
# partial (ultimele 4), niciodata integral (L.142/GDPR).
|
||||
PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"})
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009).
|
||||
"""Lifecycle trimiteri blocate: sterge / re-pune in coada.
|
||||
|
||||
Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel
|
||||
permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate —
|
||||
stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge
|
||||
logica de trimitere a worker-ului.
|
||||
Inchide lacuna: un rand `error` (creds RAR gresite) ar ramane altfel permanent si
|
||||
nereparabil. Aceste helpere adauga DOUA tranzitii controlate — stergere de randuri
|
||||
ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge logica de trimitere
|
||||
a worker-ului.
|
||||
|
||||
Invariante (decizii §2 + /autoplan #20):
|
||||
Invariante:
|
||||
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
|
||||
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
|
||||
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
|
||||
al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri
|
||||
al altui cont -> SubmissionNotFound (404, nu confirmam existenta). Doar pe randuri
|
||||
proprii in stare gresita -> SubmissionStateConflict (409).
|
||||
- Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`.
|
||||
- Ambele emit eveniment in jurnal: `submission_sters` / `submission_repus`.
|
||||
|
||||
Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011).
|
||||
Functii cu `conn` (persistenta).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -80,7 +80,7 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
||||
|
||||
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
|
||||
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
|
||||
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013).
|
||||
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare).
|
||||
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
|
||||
{"submission_id", "status_anterior", "status_nou"}.
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
|
||||
"""Helper-e utilizatori web (email + parola scrypt).
|
||||
|
||||
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
|
||||
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
|
||||
migrare cost viitoare (C9).
|
||||
migrare cost viitoare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -98,7 +98,7 @@ def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True)
|
||||
"""Seteaza/sterge rolul admin pe toti userii contului dat.
|
||||
|
||||
Ridica ValueError daca contul nu exista.
|
||||
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
|
||||
Daca contul exista dar nu are useri, e no-op silentios.
|
||||
"""
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
@@ -119,7 +119,7 @@ def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
|
||||
|
||||
|
||||
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
|
||||
"""Returneaza emailurile tuturor userilor cu is_admin=1."""
|
||||
rows = conn.execute(
|
||||
"SELECT email FROM users WHERE is_admin=1"
|
||||
).fetchall()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Panou admin web /admin. US-011 PRD 3.3b.
|
||||
"""Panou admin web /admin.
|
||||
|
||||
Rute:
|
||||
GET /admin — listeaza conturi in asteptare + active (require_admin)
|
||||
@@ -49,7 +49,7 @@ 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"])
|
||||
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
||||
# Grupare pe STARE, 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]
|
||||
@@ -79,7 +79,7 @@ async def admin_get(request: Request):
|
||||
|
||||
|
||||
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
|
||||
"""Aplica un verb de ciclu de viata 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:
|
||||
@@ -97,7 +97,7 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
|
||||
|
||||
|
||||
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.
|
||||
"""Corp comun pentru rutele de ciclu de viata: auth + CSRF + aplica verbul (bulk) + PRG.
|
||||
Evita 4 handlere copy-paste care difera doar prin verb."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
|
||||
"""Rute autentificare web: /signup, /login, /logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""CSRF token per-sesiune + validare. US-009 PRD 3.3.
|
||||
"""CSRF token per-sesiune + validare.
|
||||
|
||||
Contract pentru rutele POST web:
|
||||
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""
|
||||
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
|
||||
"""Traducere stari tehnice in text uman + clasa CSS.
|
||||
|
||||
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
||||
|
||||
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -59,7 +56,7 @@ STARI_SUBMISSION: dict[str, Eticheta] = {
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri (US-006)
|
||||
# Etichete scurte (pill) pentru coloana Stare din tabelul de trimiteri
|
||||
# Dict propriu — NU element in tuple Eticheta (ar rupe template-urile care
|
||||
# despacheteaza 3 elemente). eticheta_stare ramane neatinsa.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,7 +152,7 @@ def eticheta_rar(stare: str) -> Eticheta:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format data RAR (US-001, PRD 3.5)
|
||||
# Format data RAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_data_rar(raw: object) -> str:
|
||||
@@ -181,7 +178,7 @@ def format_data_rar(raw: object) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Motiv uman din rar_error (US-004, PRD 3.5)
|
||||
# Motiv uman din rar_error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def motiv_uman(status: str, rar_error: object) -> str:
|
||||
@@ -231,7 +228,7 @@ def motiv_uman(status: str, rar_error: object) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4)
|
||||
# parse_erori — transforma rar_error in lista 3-niveluri
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_erori(rar_error: object) -> list[dict]:
|
||||
@@ -275,7 +272,7 @@ def parse_erori(rar_error: object) -> list[dict]:
|
||||
"cauza": e.get("cauza") or e.get("message") or "",
|
||||
"fix": e.get("fix") or "",
|
||||
"field": e.get("field"),
|
||||
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal (US-001/R1).
|
||||
# Cod BRUT de catalog (ex. RAR_EROARE_SERVER) — DOAR pentru modal.
|
||||
"cod": e.get("cod"),
|
||||
})
|
||||
else:
|
||||
@@ -305,7 +302,7 @@ def parse_erori(rar_error: object) -> list[dict]:
|
||||
"cauza": data.get("cauza") or "",
|
||||
"fix": data.get("fix") or "",
|
||||
"field": data.get("field"),
|
||||
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal (US-001/R1).
|
||||
# Cod BRUT de catalog (ex. COD_NEMAPAT) — DOAR pentru modal.
|
||||
"cod": data.get("cod"),
|
||||
}]
|
||||
# Dict vechi: unmapped
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002).
|
||||
"""Middleware HTTP: request_id per cerere.
|
||||
|
||||
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
|
||||
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
|
||||
in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile.
|
||||
in handlerul de erori si in `log_event` — fara a polua semnaturile.
|
||||
|
||||
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
|
||||
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5.
|
||||
"""Rate-limit in-proces cu fereastra glisanta.
|
||||
|
||||
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client.
|
||||
Fara dependinta externa. Folosit de POST /signup cu cheia = IP client.
|
||||
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
|
||||
"""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"""Helper-e sesiune web. US-002 PRD 3.3.
|
||||
"""Helper-e sesiune web.
|
||||
|
||||
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
|
||||
Mecanism require_login: NU un dependency FastAPI care intoarce RedirectResponse
|
||||
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
||||
- require_login() RIDICA LoginRequired
|
||||
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
||||
@@ -31,7 +31,7 @@ def current_account(request: Request) -> int | None:
|
||||
|
||||
|
||||
def current_user_id(request: Request) -> int | None:
|
||||
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
|
||||
"""user_id din sesiune sau None (leaga import_attestations.confirmed_by)."""
|
||||
val = request.session.get("user_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
@@ -88,7 +88,7 @@ def require_admin(request: Request) -> int:
|
||||
|
||||
|
||||
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
||||
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
|
||||
"""Seteaza sesiunea dupa login. Curata mai intai (anti-fixare sesiune)."""
|
||||
request.session.clear()
|
||||
request.session["account_id"] = account_id
|
||||
request.session["user_id"] = user_id
|
||||
|
||||
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/romfast_logo.png
Normal file
BIN
app/web/static/romfast_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -44,12 +44,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 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,
|
||||
iar empty-state-ul tabelului ar fi redundant (US-004 / D-5.1). === #}
|
||||
{# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero
|
||||
trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #}
|
||||
{% if are_trimiteri %}
|
||||
{% include '_coada.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{#
|
||||
_coada.html — repurposat in 3.6 (US-003).
|
||||
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
|
||||
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
|
||||
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
|
||||
_coada.html — sectiunea "Trimiterile tale" inclusa pe Acasa, sub zona de upload.
|
||||
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
|
||||
#}
|
||||
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
|
||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
|
||||
@@ -21,28 +19,17 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include -->
|
||||
<!-- Bara de filtre: vehicul/data + pill-uri de stare pe acelasi rand. Pill-urile scriu
|
||||
campul hidden status si re-trimit form-ul (filtreazaStare) -> filtrul persista la reincarcari. -->
|
||||
<form id="filtre-trimiteri"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
|
||||
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
|
||||
<div>
|
||||
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
|
||||
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
|
||||
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
|
||||
{% set sf = status_filtru | default('') %}
|
||||
<select id="f-status" name="status">
|
||||
<option value="" {% if not sf %}selected{% endif %}>toate</option>
|
||||
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
|
||||
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
|
||||
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
|
||||
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
|
||||
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
|
||||
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
|
||||
</select>
|
||||
</div>
|
||||
style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
|
||||
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
|
||||
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
|
||||
<input type="hidden" id="f-page" name="page" value="1">
|
||||
<div>
|
||||
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
|
||||
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">
|
||||
@@ -56,19 +43,27 @@
|
||||
<input id="f-data-pana" type="date" name="data_pana">
|
||||
</div>
|
||||
<button type="submit">Filtreaza</button>
|
||||
{# Pill-uri de stare pe acelasi rand cu filtrele; re-randate prin OOB la reincarcarea tabelului. #}
|
||||
<span id="pills-categorii" class="pills-categorii" style="margin-left:auto;">
|
||||
{% include '_pills.html' %}
|
||||
</span>
|
||||
</form>
|
||||
|
||||
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) -->
|
||||
<!-- Nudge "Date noi": tabelul nu se reimprospateaza singur; bannerul apare doar cand
|
||||
pollerul usor detecteaza schimbari, iar utilizatorul reincarca cand vrea. -->
|
||||
<div id="nudge-trimiteri" hidden role="status" aria-live="polite">
|
||||
<span>Sunt trimiteri actualizate.</span>
|
||||
<button type="button" onclick="reincarcaTrimiteri()">Reincarca</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabelul se reincarca DOAR la: incarcarea paginii, actiunile tale (trimiteriChanged)
|
||||
sau apasarea pe Reincarca (reincarcaTrimiteri). Fara poll periodic care sa-l reseteze. -->
|
||||
<div id="submissions-wrap"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-trigger="load, every 15s, trimiteriChanged from:body"
|
||||
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"
|
||||
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
|
||||
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
|
||||
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
|
||||
a fost eliminat (rol preluat de modal). #}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{#
|
||||
_eroare.html — macro card_erori(erori) (US-006, PRD 5.4).
|
||||
_eroare.html — macro card_erori(erori).
|
||||
|
||||
Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None).
|
||||
Afiseaza 3 niveluri intr-un bloc scannabil:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6).
|
||||
{# _jurnal.html — tab Jurnal de aplicatie.
|
||||
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
|
||||
data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #}
|
||||
data + (admin) cont. #}
|
||||
<section id="jurnal-section" aria-labelledby="jurnal-heading">
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
{# Macro-uri partajate intre template-urile de import si mapari. #}
|
||||
|
||||
{# 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).
|
||||
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
|
||||
INVARIANT BACKEND: 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.
|
||||
Manual<->Auto peste checkbox, NU doua radio-uri.
|
||||
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
|
||||
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
|
||||
- checked: starea STOCATA per mapare — bifat = Auto. #}
|
||||
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
|
||||
<label class="autosend-toggle"
|
||||
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/* Selectul de cod RAR e principalul vinovat de latimea tabelelor de mapari. Il limitam ca
|
||||
tabelul sa incapa in card fara scroll orizontal -> coloana Actiuni (kebab) ramane vizibila. */
|
||||
#mapari-section td select { width:100%; max-width:240px; min-width:150px; }
|
||||
/* US-007 (R12): in card per rand (sub 767px) selectul/inputurile umplu cardul. */
|
||||
/* In card per rand (sub 767px) selectul/inputurile umplu cardul. */
|
||||
@media (max-width:767px) {
|
||||
#mapari-section td select, #mapari-section td input[type=text] { max-width:none; min-width:0; }
|
||||
}
|
||||
@@ -18,29 +18,9 @@
|
||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
{# 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">
|
||||
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
|
||||
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if pending %}
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
@@ -117,8 +97,6 @@
|
||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||
</div>
|
||||
{% else %}
|
||||
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
@@ -165,15 +143,24 @@
|
||||
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
|
||||
</td>
|
||||
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
|
||||
{# Salveaza/Sterge in meniu contextual (kebab) — randul ramane ingust. Butoanele se
|
||||
leaga prin form= de cele doua form-uri hx-post definite in prima celula a randului. #}
|
||||
<details class="kebab">
|
||||
<summary aria-label="Actiuni pentru {{ m.cod_op_service }}">⋯</summary>
|
||||
<div class="kebab-menu">
|
||||
<button type="submit" form="map-salv-{{ loop.index }}">Salveaza</button>
|
||||
<button type="submit" form="map-del-{{ loop.index }}" class="danger">Sterge</button>
|
||||
</div>
|
||||
</details>
|
||||
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
|
||||
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
|
||||
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
||||
<button type="submit" form="map-salv-{{ loop.index }}"
|
||||
class="icon-btn"
|
||||
data-dirty-form="map-salv-{{ loop.index }}"
|
||||
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="submit" form="map-del-{{ loop.index }}"
|
||||
class="icon-btn danger"
|
||||
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -187,79 +174,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||
|
||||
{% if not column_formats %}
|
||||
<div class="empty">
|
||||
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
|
||||
se retine aici si se reaplica automat la fisierele cu acelasi antet.
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Coloane</th>
|
||||
<th>Mapari (coloana → camp)</th>
|
||||
<th>Format data</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in column_formats %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;" data-eticheta="Coloane">
|
||||
<strong>{{ f.columns | length }} coloane</strong>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana → camp)">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-eticheta="Format data">
|
||||
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
|
||||
hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
|
||||
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
|
||||
<button type="submit">Salveaza data</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi acest format de coloane?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
|
||||
<div class="dt-pager" data-dt-pager></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 4: Reguli automate pe text (operation_text_rules) -->
|
||||
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
|
||||
@@ -343,7 +258,7 @@
|
||||
<button type="submit" form="rt-add">Adauga</button>
|
||||
</td>
|
||||
</tr>
|
||||
{# Preview pre-salvare (US-009): cate operatii nemapate potriveste pattern-ul. #}
|
||||
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
|
||||
<tr>
|
||||
<td colspan="4" style="padding-top:0;">
|
||||
<div id="rt-preview" aria-live="polite"></div>
|
||||
@@ -354,4 +269,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||
|
||||
{% if not column_formats %}
|
||||
<div class="empty">
|
||||
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
|
||||
se retine aici si se reaplica automat la fisierele cu acelasi antet.
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Coloane</th>
|
||||
<th>Mapari (coloana → camp)</th>
|
||||
<th>Format data</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in column_formats %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;" data-eticheta="Coloane">
|
||||
<strong>{{ f.columns | length }} coloane</strong>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana → camp)">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-eticheta="Format data">
|
||||
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
|
||||
hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
|
||||
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
|
||||
<button type="submit">Salveaza data</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi acest format de coloane?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
|
||||
<div class="dt-pager" data-dt-pager></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{# 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. #}
|
||||
{# Aceeasi grila standard ca tabelul Trimiteri: cod in .pill, denumire ca text normal
|
||||
(singura coloana care se poate rupe pe randuri inguste), empty-state in .empty. #}
|
||||
{% if rows %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
|
||||
15
app/web/templates/_pills.html
Normal file
15
app/web/templates/_pills.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{# Pill-uri de filtrare a starii, randate in bara de filtre (_coada.html) si re-randate
|
||||
prin OOB la fiecare reincarcare a tabelului (_submissions.html). Stare activa =
|
||||
status_filtru. "Toate" reseteaza filtrul; categoriile apar doar cand au n>0. #}
|
||||
<button type="button" class="pill-cat pill-cat-reset" data-status=""
|
||||
aria-pressed="{{ 'true' if not status_filtru else 'false' }}"
|
||||
onclick="filtreazaStare(this, '')">Toate</button>
|
||||
{% for pill in pills_categorii %}
|
||||
<button type="button" class="pill-cat" data-status="{{ pill.status }}"
|
||||
aria-pressed="{{ 'true' if status_filtru == pill.status else 'false' }}"
|
||||
style="color:var({{ pill.color_var }}); border-color:var({{ pill.color_var }});"
|
||||
onclick="filtreazaStare(this, '{{ pill.status }}')">
|
||||
{{ pill.label }}
|
||||
<span class="pill-cat-n" style="background:var({{ pill.color_var }});">{{ pill.n }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand — US-002) -->
|
||||
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand) -->
|
||||
{% set status_labels = [
|
||||
('ok', 'gata de trimis'),
|
||||
('needs_review', 'verifica valori'),
|
||||
@@ -108,7 +108,7 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
|
||||
altfel Enter intr-un camp ar declansa trimiterea ireversibila — D-3.3). Bifele
|
||||
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
|
||||
needs_review se asociaza la #confirm-form prin atributul form=. -->
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
@@ -142,7 +142,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<div class="sticky-bar">
|
||||
<div style="flex:1; min-width:280px;">
|
||||
<!-- Banner declarant (D12) — direct deasupra input-ului N -->
|
||||
<!-- Banner declarant — direct deasupra input-ului N -->
|
||||
<div class="banner warn" style="margin-bottom:10px; padding:8px 12px; border-radius:6px;"
|
||||
role="note" aria-live="polite">
|
||||
Confirmand, TU esti declarantul acestor
|
||||
@@ -199,7 +199,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Contor "gata de trimis" citit din DOM (data-ok), ca OOB swap-ul de la editare
|
||||
sa actualizeze N fara a re-randa sectiunea (US-002). -->
|
||||
sa actualizeze N fara a re-randa sectiunea. -->
|
||||
<span id="preview-ok-count" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
||||
|
||||
<div style="padding:8px 0 4px;">
|
||||
@@ -212,13 +212,13 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* D-1.2: un singur sticky bar pe ecran — cat preview-ul de import e activ,
|
||||
/* Un singur sticky bar pe ecran — cat preview-ul de import e activ,
|
||||
ascunde sectiunea Trimiteri de pe Acasa (se reveleaza la reset/commit din _upload.html). */
|
||||
var trim = document.getElementById('trimiteri-section');
|
||||
if (trim) trim.style.display = 'none';
|
||||
|
||||
/* nOk se citeste din DOM (#preview-ok-count[data-ok]) ca OOB swap-ul de la editare
|
||||
sa-l poata actualiza fara re-randarea sectiunii (D-3.1/D-3.4). */
|
||||
sa-l poata actualiza fara re-randarea sectiunii. */
|
||||
function getOk() {
|
||||
var el = document.getElementById('preview-ok-count');
|
||||
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
|
||||
@@ -231,7 +231,7 @@
|
||||
var inp = document.getElementById('n-confirmat');
|
||||
var disp = document.getElementById('n-display');
|
||||
var btn = document.getElementById('confirm-btn');
|
||||
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion D-3.2). */
|
||||
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
|
||||
var editing = document.querySelector('tr[data-editing="1"]') !== null;
|
||||
if (inp) inp.value = total;
|
||||
if (disp) disp.textContent = total;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{#
|
||||
_preview_rand.html — un singur rand de preview import (US-002, 3.6).
|
||||
_preview_rand.html — un singur rand de preview import.
|
||||
Doua moduri:
|
||||
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni.
|
||||
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU
|
||||
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html.
|
||||
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section (D-3.1).
|
||||
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
|
||||
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
|
||||
#}
|
||||
{%- set res = row.resolved -%}
|
||||
@@ -80,7 +80,7 @@
|
||||
</tr>
|
||||
<script>
|
||||
(function() {
|
||||
/* Mutual-exclusion (D-3.2/3.6): cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
|
||||
/* Mutual-exclusion: cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
|
||||
var btn = document.getElementById('confirm-btn');
|
||||
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
|
||||
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
|
||||
@@ -152,7 +152,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% if include_oob %}
|
||||
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea (D-3.1). #}
|
||||
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
|
||||
{% set status_labels = [
|
||||
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
|
||||
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}
|
||||
|
||||
@@ -47,38 +47,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
|
||||
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
|
||||
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
|
||||
{% if blocate_actionabil %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:18px; flex-wrap:wrap;">
|
||||
{% for cat in blocate_actionabil %}
|
||||
<div style="min-width:200px;">
|
||||
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
|
||||
deep-link server-side (?tab=acasa&status=...). #}
|
||||
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
|
||||
href="/?tab=acasa&status={{ cat.status }}"
|
||||
hx-get="/_fragments/submissions?status={{ cat.status }}"
|
||||
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
|
||||
{{ cat.eticheta[0] }} ({{ cat.n }}) ›
|
||||
</a>
|
||||
<ul style="list-style:none; margin:6px 0 0; padding:0;">
|
||||
{% for r in cat.randuri %}
|
||||
<li class="muted" style="font-size:12px;">
|
||||
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if cat.rest %}
|
||||
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{#
|
||||
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri.
|
||||
Reincarcarea (hx-include="#filtre-trimiteri") preia automat pagina curenta.
|
||||
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
|
||||
#}
|
||||
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
||||
|
||||
{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu
|
||||
contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #}
|
||||
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
|
||||
|
||||
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
|
||||
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
||||
|
||||
{% if rows %}
|
||||
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||
<form id="bulk-trimiteri"
|
||||
hx-post="/trimiteri/sterge-bulk"
|
||||
@@ -29,9 +43,8 @@
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
|
||||
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
|
||||
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
|
||||
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
|
||||
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
|
||||
<tr id="trimitere-row-{{ r.id }}"
|
||||
class="trimitere-row"
|
||||
data-detaliu-id="{{ r.id }}"
|
||||
@@ -51,8 +64,8 @@
|
||||
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
||||
<td class="col-stare" data-eticheta="Stare">
|
||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||
{# PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic, `s-error`
|
||||
pe error/needs_* (singurele stari pe care `eticheta_problema` e ne-goala).
|
||||
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
|
||||
(singurele stari pe care `eticheta_problema` e ne-goala).
|
||||
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
|
||||
{% if r.eticheta_problema %}
|
||||
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
|
||||
@@ -61,13 +74,14 @@
|
||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||
{{ r.prez.vehicul_nr }}
|
||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
|
||||
{# VIN pe rand separat sub nr (element block, nu span inline) #}
|
||||
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-operatie" data-eticheta="Operatie">
|
||||
<div>{{ r.prez.operatie }}</div>
|
||||
{# PRD 5.9 US-002: doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip
|
||||
muted discret; cand nemapat afiseaza "nemapat" muted (comportament 5.8). #}
|
||||
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
|
||||
cand nemapat afiseaza "nemapat" muted. #}
|
||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
||||
{% else %}
|
||||
@@ -83,6 +97,105 @@
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#
|
||||
Paginare numerotata.
|
||||
Afisata doar cand exista mai mult de o pagina.
|
||||
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
|
||||
Pagina curenta: aria-current="page" (semantic).
|
||||
#}
|
||||
{% if total is defined %}
|
||||
<div aria-live="polite"
|
||||
style="font-size:12px; color:var(--muted); text-align:right; margin-top:6px; margin-bottom:2px;">
|
||||
{% if total == 0 %}
|
||||
0 trimiteri
|
||||
{% else %}
|
||||
{{ page_start }}–{{ page_end }} din {{ total }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pages is defined and pages > 1 %}
|
||||
{#
|
||||
Construim param-string pentru filtrele curente (fara page) — refolosit in fiecare link.
|
||||
Filtrul status vine din pill-uri (nu din form); il pastram in URL.
|
||||
#}
|
||||
{% set pq = "" %}
|
||||
{% if f_status %}{% set pq = pq + "&status=" + f_status %}{% endif %}
|
||||
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
|
||||
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
|
||||
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
|
||||
|
||||
<nav aria-label="Paginare trimiteri"
|
||||
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
|
||||
|
||||
{# Buton Anterior #}
|
||||
{% if page > 1 %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ page - 1 }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);"
|
||||
aria-label="Pagina anterioara">
|
||||
«
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" disabled
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--muted);
|
||||
opacity:0.4; cursor:default;"
|
||||
aria-label="Pagina anterioara (indisponibila)">
|
||||
«
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Numerele de pagina #}
|
||||
{% for p in range(1, pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button type="button"
|
||||
aria-current="page"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:default;
|
||||
border:1px solid var(--accent); background:var(--accent); color:#fff;
|
||||
font-weight:700;">
|
||||
{{ p }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ p }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);">
|
||||
{{ p }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Buton Urmator #}
|
||||
{% if page < pages %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ page + 1 }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);"
|
||||
aria-label="Pagina urmatoare">
|
||||
»
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" disabled
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--muted);
|
||||
opacity:0.4; cursor:default;"
|
||||
aria-label="Pagina urmatoare (indisponibila)">
|
||||
»
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% elif filtru_activ %}
|
||||
<div class="empty">
|
||||
Nimic pe filtrul curent.
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{% from "_eroare.html" import card_erori %}
|
||||
{% import '_macros.html' as ui %}
|
||||
{# PRD 5.9 US-004: detaliu editabil in-place, butoane consolidate, ordine verticala R10.
|
||||
Fragmentul se swap-uieste in corpul modalului global (#detaliu-modal-body). Heading-ul
|
||||
poarta id-ul folosit de aria-labelledby al dialogului.
|
||||
R9: operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
|
||||
{# Detaliu editabil in-place. Fragmentul se swap-uieste in corpul modalului global
|
||||
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului.
|
||||
Operatie + cod RAR rezolvat apar IMPREUNA, read-only, folosind `prez.cod_rar`
|
||||
(fallback „nemapat"), fara eticheta separata „Cod RAR". #}
|
||||
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
|
||||
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
|
||||
|
||||
{# === R10 (1): header — #id + pill + motiv uman === #}
|
||||
{# === Header — #id + pill + motiv uman === #}
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
|
||||
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
||||
@@ -19,14 +18,16 @@
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (2): bloc eroare blocanta cand exista === #}
|
||||
{% if erori_3n %}
|
||||
{# === Bloc eroare blocanta — DOAR in read-only.
|
||||
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
|
||||
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
|
||||
{% if not editabil and erori_3n %}
|
||||
<div style="margin:0 0 14px;">
|
||||
{{ card_erori(erori_3n) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (3) + R9: mapare inline (PRD 5.7) — alege cod RAR pentru operatiile nemapate.
|
||||
{# === Mapare inline — alege cod RAR pentru operatiile nemapate.
|
||||
Cand nemapate_inline, linia „Operatie: X · nemapat" apare in formularul de mai jos
|
||||
(cod_afis = nemapat), iar aici e picker-ul; dupa mapare, re-render arata codul rezolvat. === #}
|
||||
{% if nemapate_inline %}
|
||||
@@ -76,7 +77,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (4): formular editabil (needs_data/needs_mapping) SAU context read-only.
|
||||
{# === Formular editabil (needs_data/needs_mapping) SAU context read-only.
|
||||
Zero dublare: campurile vehiculului apar O SINGURA DATA — editabile cand randul e
|
||||
corectabil, altfel read-only. Operatie + cod RAR read-only deasupra campurilor. === #}
|
||||
{% if editabil %}
|
||||
@@ -88,6 +89,13 @@
|
||||
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Erori fara camp (field None) nu dispar silentios in editare —
|
||||
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
|
||||
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
|
||||
{% for e in erori_3n if not e.field %}
|
||||
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% macro camp(nume, eticheta, valoare, tip='text') %}
|
||||
<div style="margin-bottom:10px;">
|
||||
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
|
||||
@@ -105,11 +113,40 @@
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #}
|
||||
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
||||
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
|
||||
{% if nomenclator_rar %}
|
||||
<div style="margin:0 0 12px;">
|
||||
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
|
||||
{% if prez.operatie and prez.operatie != '—' %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
|
||||
{% endif %}
|
||||
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
|
||||
aria-label="Alege operatia RAR din nomenclator">
|
||||
<option value="">— pastrat ({{ cod_afis }}) —</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Operatie + cod RAR read-only deasupra campurilor (fara eticheta „Cod RAR"). #}
|
||||
<div style="margin:0 0 12px;">
|
||||
<div class="muted" style="font-size:12px;">Operatie</div>
|
||||
<div>{{ prez.operatie }} · {{ cod_afis }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
|
||||
{% if prez.op_service_cod %}
|
||||
<div style="margin:0 0 12px;">
|
||||
<div class="muted" style="font-size:12px;">Operatie service</div>
|
||||
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
|
||||
@@ -122,7 +159,7 @@
|
||||
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
|
||||
</div>
|
||||
|
||||
{# === R10 (5): actiune primara conditionata de stare (R2). needs_data/needs_mapping
|
||||
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
|
||||
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
|
||||
<div style="margin-top:14px;">
|
||||
<button type="submit">Salveaza si retrimite</button>
|
||||
@@ -139,25 +176,49 @@
|
||||
<div style="word-break:break-all;">{{ prez.vin }}</div>
|
||||
</div>
|
||||
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} · {{ cod_afis }}</div></div>
|
||||
{# Operatie service (cod intern + denumire), distinct de operatia RAR.
|
||||
op_service_cod="" cand lipseste → randul absent (fara "—"). #}
|
||||
{% if prez.op_service_cod %}
|
||||
<div><div class="muted" style="font-size:12px;">Operatie service</div>
|
||||
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
|
||||
{% endif %}
|
||||
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
|
||||
{# === Actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT === #}
|
||||
{% if status == 'error' or gestionabil %}
|
||||
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil). #}
|
||||
{# Error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
|
||||
{# campuri vehicul, dar se poate schimba cod_prestatie prin acelasi formular). #}
|
||||
{% if status == 'error' %}
|
||||
<form hx-post="/trimitere/{{ id }}/repune"
|
||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||
hx-disabled-elt="find button" style="margin:0 0 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{# Select cod_prestatie optional in formularul /repune (doar pentru error). #}
|
||||
{% if nomenclator_rar %}
|
||||
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
|
||||
Operatie RAR (optional — schimba codul si re-pune)
|
||||
</label>
|
||||
<select id="cod-rar-error-{{ id }}" name="cod_prestatie"
|
||||
aria-label="Alege operatia RAR din nomenclator"
|
||||
style="width:100%; margin-bottom:8px; font-size:13px;">
|
||||
<option value="">— pastrat ({{ cod_prestatie_curent }}) —</option>
|
||||
{% for item in nomenclator_rar %}
|
||||
<option value="{{ item.cod_prestatie }}"
|
||||
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<button type="submit">Re-pune in coada</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# R11: UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
|
||||
{# UN SINGUR Sterge, outline distructiv (var(--err)), pe rand separat, full-width pe mobil. #}
|
||||
{% if gestionabil %}
|
||||
<form hx-post="/trimitere/{{ id }}/sterge"
|
||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||
@@ -173,7 +234,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (6): Detalii tehnice — colapsat implicit === #}
|
||||
{# === Detalii tehnice — colapsat implicit === #}
|
||||
<details style="margin-top:14px;">
|
||||
<summary class="muted" style="font-size:12px; cursor:pointer;">Detalii tehnice</summary>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 24px; margin-top:10px;">
|
||||
@@ -195,7 +256,6 @@
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
{# PRD 5.9 US-004 (R4): scriptul inline vechi (marcheazaDetaliuDeschis / scrollIntoView pe
|
||||
randul-sibling) a fost eliminat de US-003. Focus-ul post-swap (incl. re-render corectie/
|
||||
mapare) e gestionat de htmx:afterSettle pe #detaliu-modal-body din base.html. R5: inchiderea
|
||||
modalului pe succes (queued/sterge) vine din HX-Trigger `inchideModal` emis de rute. #}
|
||||
{# Focus-ul post-swap (incl. re-render corectie/mapare) e gestionat de htmx:afterSettle pe
|
||||
#detaliu-modal-body din base.html. Inchiderea modalului pe succes (queued/sterge) vine
|
||||
din HX-Trigger `inchideModal` emis de rute. #}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div id="import-section">
|
||||
{% 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). #}
|
||||
{# Bara de upload accentuata (border de accent) ca sa ramana punctul
|
||||
de intrare evident chiar cu tabelul Trimiteri lung dedesubt. #}
|
||||
{% from '_eroare.html' import card_erori %}
|
||||
<div class="card" style="border-color:var(--accent);">
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
var dz = document.getElementById('drop-zone');
|
||||
var frm = document.getElementById('upload-form');
|
||||
|
||||
/* US-003 (3.6): un singur sticky bar pe ecran — cand re-apare zona de upload
|
||||
/* Un singur sticky bar pe ecran — cand re-apare zona de upload
|
||||
(reset sau dupa commit), sectiunea Trimiteri redevine vizibila. */
|
||||
var trim = document.getElementById('trimiteri-section');
|
||||
if (trim) trim.style.display = '';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}Conturi clienti — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
||||
{# Metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
|
||||
{% set VERBS = {
|
||||
'activate': ('Activeaza', '/admin/activate', ''),
|
||||
'block': ('Blocheaza', '/admin/block', ''),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script>
|
||||
// US-002 (3.6): raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||
// elemente OOB non-rand (#preview-rezumat, #preview-ok-count). Fara fragmente-template,
|
||||
// htmx parseaza raspunsul care incepe cu <tr> in context de tabel (<table><tbody>) si
|
||||
// "foster-parent"-eaza div/span-urile OOB afara din fragment -> swapError + contoare pierdute.
|
||||
@@ -14,12 +14,16 @@
|
||||
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.
|
||||
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
|
||||
// paint; seteaza data-theme pe <html> sincron, fara blink.
|
||||
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
||||
(function() {
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (!t) {
|
||||
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto
|
||||
if (t === 'auto') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
@@ -29,19 +33,100 @@
|
||||
})();
|
||||
</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; }
|
||||
/* IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
|
||||
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex);
|
||||
reflow-ul vizibil pe VIN/coduri e acceptat explicit. */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Paleta dark (default) — accent azur ROMFAST */
|
||||
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
||||
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
|
||||
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
|
||||
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
|
||||
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
|
||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
||||
* { box-sizing:border-box; }
|
||||
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
|
||||
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
|
||||
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
|
||||
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
|
||||
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
|
||||
/* Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line);
|
||||
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; }
|
||||
.header-left { display:flex; align-items:center; }
|
||||
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
||||
/* Logo ROMFAST aliniat stanga; transparent, ok pe dark/light/petrol fara filtre de culoare. */
|
||||
.brand-logo { height:60px; width:auto; display:block; margin:0; }
|
||||
.header-center .env { font-size:11px; margin-top:2px; }
|
||||
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
|
||||
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; }
|
||||
@@ -56,6 +141,28 @@
|
||||
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.empty { color:var(--muted); padding:24px; text-align:center; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
|
||||
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
|
||||
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
|
||||
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
.pill-cat { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:99px;
|
||||
font-size:12px; font-weight:600; cursor:pointer; background:transparent;
|
||||
border:1.5px solid var(--line); color:var(--muted); min-height:30px;
|
||||
transition:background .15s, color .15s; }
|
||||
.pill-cat:hover { filter:brightness(1.1); }
|
||||
.pill-cat:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.pill-cat-n { font-size:11px; font-weight:700; color:var(--card); padding:0 5px;
|
||||
border-radius:99px; min-width:18px; text-align:center; }
|
||||
.pill-cat[aria-pressed="true"] { background:currentColor; color:var(--card); border-color:currentColor; }
|
||||
.pill-cat[aria-pressed="true"] .pill-cat-n { background:var(--card) !important; color:currentColor; }
|
||||
.pill-cat-reset[aria-pressed="true"] { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
/* Nudge "Date noi": apare doar cand pollerul usor detecteaza schimbari; tabelul nu se
|
||||
schimba singur niciodata, utilizatorul reincarca cand vrea. */
|
||||
#nudge-trimiteri { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:0 0 12px;
|
||||
padding:8px 12px; border-radius:8px; font-size:13px;
|
||||
border:1px solid var(--accent);
|
||||
background:color-mix(in srgb, var(--accent) 12%, var(--card)); }
|
||||
#nudge-trimiteri[hidden] { display:none; }
|
||||
#nudge-trimiteri button { font-size:13px; padding:5px 12px; min-height:32px; }
|
||||
.s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);}
|
||||
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
|
||||
.s-ok{color:var(--ok);}
|
||||
@@ -94,7 +201,7 @@
|
||||
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.08); }
|
||||
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||||
/* Tab-bar (US-003) */
|
||||
/* Tab-bar */
|
||||
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
|
||||
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
|
||||
scrollbar-width:none; }
|
||||
@@ -108,25 +215,30 @@
|
||||
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 3 niveluri */
|
||||
.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-camp { font-family:"IBM Plex Mono",ui-monospace,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 */
|
||||
/* Meniu hamburger cont — 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); }
|
||||
/* Variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
|
||||
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
.icon-btn.dirty:hover { filter:brightness(0.9); }
|
||||
.icon-btn.danger { color:var(--err); border-color:var(--err); }
|
||||
.icon-btn.danger:hover, .icon-btn.danger:focus-visible { background:var(--err); color:#fff; }
|
||||
.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; }
|
||||
@@ -139,7 +251,7 @@
|
||||
.cont-menu form { margin:0; }
|
||||
/* Kebab partajat (actiuni per-rand in tabele). Meniul e position:fixed si pozitionat de JS:
|
||||
altfel `.tablewrap { overflow-x:auto }` induce overflow-y:auto si TAIE dropdown-ul pe ultimul
|
||||
rand (bug 5.5 — meniul nu se vedea). fixed scoate meniul din contextul de clipping al tabelului. */
|
||||
rand. fixed scoate meniul din contextul de clipping al tabelului. */
|
||||
.kebab { position:relative; display:inline-block; }
|
||||
.kebab > summary { list-style:none; cursor:pointer; display:inline-flex; align-items:center;
|
||||
justify-content:center; min-height:32px; min-width:32px; padding:4px 10px;
|
||||
@@ -167,7 +279,7 @@
|
||||
.dt-pager button { background:transparent; color:var(--ink); border:1px solid var(--line);
|
||||
padding:5px 12px; min-height:32px; }
|
||||
.dt-pager button:disabled { opacity:.45; cursor:default; }
|
||||
/* === Tabel trimiteri (PRD 5.8 US-007): fara scroll orizontal. SCOPAT prin
|
||||
/* === Tabel trimiteri: fara scroll orizontal. SCOPAT prin
|
||||
.tabel-trimiteri ca sa NU strice celelalte tabele (.tablewrap e partajat de
|
||||
Mapari/Formate). Permitem wrap controlat pe coloanele text + latimi rezonabile. === */
|
||||
.tabel-trimiteri table { table-layout:fixed; }
|
||||
@@ -181,16 +293,15 @@
|
||||
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
|
||||
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
||||
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
||||
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
/* Codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
|
||||
font-size:12px; padding:1px 7px; border:1px solid var(--line);
|
||||
border-radius:99px; color:var(--muted); }
|
||||
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
|
||||
o coloreaza (apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
|
||||
/* Eticheta umana scurta sub pill — text mic; clasa `s-error` o coloreaza
|
||||
(apare doar pe error/needs_*). Stare prin text, nu doar culoare. */
|
||||
.tabel-trimiteri .eticheta-problema { font-size:12px; line-height:1.3; margin-top:3px; }
|
||||
/* PRD 5.9 US-002 (R8): randul e clickabil (deschide modalul) -> tinta de atins >=44px
|
||||
(touch) + afordanta hover/focus. Inlocuieste vechea regula `@media pointer:coarse
|
||||
.chevron` (chevron eliminat); este SINGURA regula 44px pe rand. */
|
||||
/* Randul e clickabil (deschide modalul) -> tinta de atins >=44px (touch) +
|
||||
afordanta hover/focus. */
|
||||
.tabel-trimiteri tr.trimitere-row { min-height:44px; }
|
||||
.tabel-trimiteri tr.trimitere-row > td { padding-top:11px; padding-bottom:11px; }
|
||||
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
@@ -200,10 +311,10 @@
|
||||
@media (max-width:1024px) {
|
||||
.tabel-trimiteri .col-actualizat { display:none; }
|
||||
}
|
||||
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
|
||||
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
|
||||
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll
|
||||
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
|
||||
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
|
||||
`@media (max-width:767px)` US-006 de mai jos. === */
|
||||
`@media (max-width:767px)` de mai jos. === */
|
||||
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
|
||||
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
|
||||
.modal-overlay[hidden] { display:none; }
|
||||
@@ -220,9 +331,9 @@
|
||||
body.modal-open { overflow:hidden; }
|
||||
.modal-eroare { padding:16px 4px; }
|
||||
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
|
||||
/* === PRD 5.9 US-006: fundatie responsive mobil (<768px) ===
|
||||
/* === Fundatie responsive mobil (<768px) ===
|
||||
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
|
||||
de trimiteri (5.8, pastrat), modal full-screen, header/nav colapsat cu tinte touch
|
||||
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
|
||||
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
|
||||
@media (max-width:767px) {
|
||||
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
|
||||
@@ -245,21 +356,26 @@
|
||||
padding:16px; padding-top:56px; overflow-y:auto; }
|
||||
.modal-close { width:44px; height:44px; top:8px; right:8px; font-size:24px; }
|
||||
|
||||
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
|
||||
/* Actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
|
||||
.detaliu-actiuni-jos button { width:100%; }
|
||||
|
||||
/* Header + nav colapsate: header se rupe pe linii, fara scroll orizontal de pagina;
|
||||
tintele touch (toggle tema/cont, taburi, itemi meniu cont) cresc la >=44px. */
|
||||
header { padding:12px 16px; flex-wrap:wrap; gap:8px; }
|
||||
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
|
||||
Randul 1: [logo ROMFAST stanga] [controale dreapta] (margin-left:auto pe .header-right).
|
||||
Randul 2: [titlu + env mic centrat, full-width]. Fara scroll orizontal, tinte >=44px. */
|
||||
header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; min-height:0; }
|
||||
.brand-logo { height:44px; }
|
||||
.header-left { order:0; flex:0 0 auto; }
|
||||
.header-center { order:2; width:100%; text-align:center; }
|
||||
.header-right { order:1; margin-left:auto; flex:0 0 auto; }
|
||||
header h1 { font-size:17px; }
|
||||
main { padding:16px; }
|
||||
.icon-btn { min-height:44px; min-width:44px; }
|
||||
.tab-link { min-height:44px; padding:10px 14px; }
|
||||
.cont-menu a, .cont-menu button { min-height:44px; }
|
||||
|
||||
/* === PRD 5.9 US-007 (R12): paginile de continut pe mobil ===
|
||||
/* === Paginile de continut pe mobil ===
|
||||
Tabele ACTIONABILE (Mapari) -> card per rand. Clasa proprie `.tabel-card`,
|
||||
scopata SEPARAT de `.tabel-trimiteri` (5.8) ca sa NU strice cardurile de
|
||||
scopata SEPARAT de `.tabel-trimiteri` ca sa NU strice cardurile de
|
||||
trimiteri. Tabele DENSE read-only (Jurnal, Nomenclator) + Admin raman in
|
||||
`.tablewrap` (scroll orizontal CONTAINED, definit global mai sus). */
|
||||
.tabel-card table { table-layout:auto; }
|
||||
@@ -291,11 +407,11 @@
|
||||
#card-cont button, #form-test-cheie button,
|
||||
#jurnal-section #filtre-jurnal button { min-height:44px; width:100%; }
|
||||
|
||||
/* === PRD 5.9 US-008: Acasa (upload, status, filtre) + login/signup pe mobil ===
|
||||
/* === Acasa (upload, status, filtre) + login/signup pe mobil ===
|
||||
Zona de upload, bara de status si bara de filtre (`_coada.html`) stiveaza pe O
|
||||
coloana sub 767px; inputuri/butoane full-width cu tinta touch >=44px. Scopat pe
|
||||
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri (5.8),
|
||||
modalul sau paginile de continut (US-007). */
|
||||
id-urile sectiunilor de pe Acasa ca sa NU atinga tabelul de trimiteri,
|
||||
modalul sau paginile de continut. */
|
||||
/* Bara de upload: zona slim (returning user) trece pe coloana; butonul full-width. */
|
||||
#import-section .drop-zone { flex-direction:column; align-items:stretch; text-align:left; }
|
||||
#import-section #upload-btn { width:100%; min-height:44px; }
|
||||
@@ -315,22 +431,36 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{# Grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). #}
|
||||
<header>
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
|
||||
{# Celula stanga: logo ROMFAST #}
|
||||
<div class="header-left">
|
||||
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #}
|
||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||
</div>
|
||||
{# Celula centru: titlu + badge env mic #}
|
||||
<div class="header-center">
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
</div>
|
||||
{# Celula dreapta: comutator tema + versiune + meniu cont #}
|
||||
<div class="header-right">
|
||||
<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.
|
||||
{# Meniu cont: 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>
|
||||
{# Mapari, cu badge needs_mapping. #}
|
||||
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
|
||||
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
|
||||
<hr>
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
@@ -346,11 +476,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
|
||||
<span id="tema-live" role="status" aria-live="polite"
|
||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
|
||||
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
|
||||
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/
|
||||
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #}
|
||||
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
|
||||
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
|
||||
#detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/mapare/
|
||||
lifecycle. Traieste in afara #submissions-wrap -> poll-ul nu-l atinge. #}
|
||||
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
|
||||
aria-labelledby="detaliu-modal-titlu" hidden>
|
||||
<div class="modal-backdrop" data-modal-close></div>
|
||||
@@ -360,41 +493,49 @@
|
||||
</div>
|
||||
</div>
|
||||
<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.
|
||||
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
|
||||
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
||||
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
||||
(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)';
|
||||
}
|
||||
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
|
||||
var ICONS = {light:'☀', dark:'☾', petrol:'◐', auto:'◙'};
|
||||
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
|
||||
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
|
||||
function _stored() {
|
||||
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
|
||||
}
|
||||
function _resolved(stored) {
|
||||
if (stored !== 'auto') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
function _syncButton(stored) {
|
||||
var s = VALID[stored] ? stored : 'auto';
|
||||
btn.innerHTML = ICONS[s];
|
||||
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
||||
btn.title = LABELS[s]; // doar numele temei (ex. "Petrol"), nu ciclul intreg
|
||||
}
|
||||
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
|
||||
function _setTheme(t) {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-theme', _resolved(t));
|
||||
try { localStorage.setItem('theme', t); } catch(e) {}
|
||||
_syncIcon(t);
|
||||
_syncButton(t);
|
||||
var live = document.getElementById('tema-live');
|
||||
if (live) live.textContent = 'Tema: ' + LABELS[t] + (t === 'auto' ? ' (urmeaza sistemul)' : '');
|
||||
}
|
||||
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
|
||||
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||
// Init: sincronizeaza iconita din starea stocata (fara a scrie in localStorage).
|
||||
_syncButton(_stored());
|
||||
btn.addEventListener('click', function() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
_setTheme(cur === 'dark' ? 'light' : 'dark');
|
||||
var cur = _stored();
|
||||
var idx = CYCLE.indexOf(cur);
|
||||
_setTheme(CYCLE[(idx + 1) % CYCLE.length]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
|
||||
// Meniu cont: 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');
|
||||
@@ -470,6 +611,19 @@
|
||||
window.addEventListener('resize', function() { closeAll(null); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Dirty state pentru butoanele de salvare din tabelele de mapari.
|
||||
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
|
||||
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
|
||||
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
|
||||
// Delegare pe document → supravietuieste swap-urilor HTMX (#mapari-section).
|
||||
document.addEventListener('change', function(e) {
|
||||
var el = e.target;
|
||||
if (el.tagName !== 'SELECT' || !el.form || !el.form.id) return;
|
||||
var saveBtn = document.querySelector('button[data-dirty-form="' + el.form.id + '"]');
|
||||
if (saveBtn) saveBtn.classList.add('dirty');
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
|
||||
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste
|
||||
@@ -534,11 +688,11 @@
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
|
||||
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
|
||||
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
|
||||
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
|
||||
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
|
||||
// Modal detaliu trimitere: detaliul se incarca prin HTMX in #detaliu-modal-body
|
||||
// (in afara #submissions-wrap, deci poll-ul nu-l atinge). Aici: deschidere la click
|
||||
// pe rand, inchidere (x/Esc/backdrop), focus-trap, scroll-lock, inert+aria-hidden pe
|
||||
// <main>, stare de eroare la load esuat, inchidere pe succes corectie/sterge
|
||||
// (HX-Trigger inchideModal).
|
||||
(function() {
|
||||
var overlay = document.getElementById('modal-detaliu');
|
||||
if (!overlay) return;
|
||||
@@ -555,7 +709,7 @@
|
||||
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
|
||||
function(el) { return el.offsetParent !== null || el === document.activeElement; });
|
||||
}
|
||||
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
|
||||
// focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
|
||||
function trapFocus(e) {
|
||||
if (e.key !== 'Tab') return;
|
||||
var f = focusable();
|
||||
@@ -589,7 +743,6 @@
|
||||
if (t && t.focus) t.focus(); // focus readus pe rand
|
||||
}
|
||||
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
|
||||
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
|
||||
window.inchideDetaliu = function() { close(); };
|
||||
|
||||
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
|
||||
@@ -612,7 +765,7 @@
|
||||
var f = focusable();
|
||||
if (f.length) f[0].focus();
|
||||
});
|
||||
// R5: load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
|
||||
// Load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
|
||||
body.addEventListener('htmx:responseError', function(evt) {
|
||||
if (!isOpen()) return;
|
||||
var elt = evt.detail && evt.detail.elt;
|
||||
@@ -630,7 +783,7 @@
|
||||
{ target: body, swap: 'innerHTML' });
|
||||
});
|
||||
|
||||
// R5: inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
|
||||
// Inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
|
||||
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
|
||||
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
|
||||
document.body.addEventListener('inchideModal', function() { close(); });
|
||||
@@ -647,45 +800,49 @@
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Poll-guard (PRD 5.9 US-005, R6). Inlocuieste vechea pauza pe „rand expandat" (5.8):
|
||||
// randul-sibling de detaliu nu mai exista (US-003 l-a mutat in modalul global, care
|
||||
// traieste in afara #submissions-wrap -> un swap de poll nu-l atinge). Aici oprim
|
||||
// poll-ul de 15s de a REINCARCA lista cat timp (a) modalul e deschis SAU (b) exista
|
||||
// cel putin un checkbox de bulk bifat — altfel modalul s-ar reseta / bifele s-ar sterge.
|
||||
//
|
||||
// CRITIC (F5): blocam DOAR trigger-ul periodic. In htmx `load`/`every 15s` declanseaza
|
||||
// requestul FARA `triggeringEvent`; `trimiteriChanged` (HX-Trigger dupa corectie/stergere)
|
||||
// si submit-ul/filtrul AU `triggeringEvent` -> TREC MEREU. Asa evitam blocajul permanent:
|
||||
// daca randul bifat paraseste filtrul, pauza nu ramane lipita (pauza e legata strict de
|
||||
// trigger-ul periodic, nu de o stare „sticky"). Anularea unui `htmx:beforeRequest` NU
|
||||
// opreste timer-ul htmx (se reprogrameaza singur) -> poll-ul reia automat la urmatorul
|
||||
// tic cand ambele conditii dispar; nu se pierde scroll, focus sau selectia de bife.
|
||||
// Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai
|
||||
// schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica
|
||||
// doar versiunea datelor si arata nudge-ul "Date noi" cand difera. Reincarcarea
|
||||
// (pill, nudge sau actiune) trece prin form -> pastreaza filtrul/pagina curenta.
|
||||
(function() {
|
||||
function modalDeschis() {
|
||||
var o = document.getElementById('modal-detaliu');
|
||||
return !!(o && !o.hidden);
|
||||
// Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
|
||||
window.filtreazaStare = function(btn, status) {
|
||||
var form = document.getElementById('filtre-trimiteri');
|
||||
if (!form) return;
|
||||
var hs = document.getElementById('f-status'); if (hs) hs.value = status || '';
|
||||
var hp = document.getElementById('f-page'); if (hp) hp.value = '1';
|
||||
document.querySelectorAll('#pills-categorii .pill-cat').forEach(function(b) {
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
if (btn) btn.setAttribute('aria-pressed', 'true');
|
||||
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||
};
|
||||
// Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri) si ascunde nudge-ul.
|
||||
window.reincarcaTrimiteri = function() {
|
||||
var n = document.getElementById('nudge-trimiteri'); if (n) n.hidden = true;
|
||||
if (window.htmx) htmx.trigger('#submissions-wrap', 'reincarcaTrimiteri');
|
||||
};
|
||||
|
||||
// Poller "Date noi": compara versiunea datelor cu cea cu care s-a randat tabelul.
|
||||
// Daca difera, arata nudge-ul; daca nu, nu atinge nimic. JSON usor, fara re-render.
|
||||
var INTERVAL = 20000;
|
||||
function versiuneCurenta() {
|
||||
var e = document.getElementById('trimiteri-versiune');
|
||||
return e ? e.getAttribute('data-v') : null;
|
||||
}
|
||||
function existaBifa() {
|
||||
return !!document.querySelector('#submissions-wrap input[name="submission_id"]:checked');
|
||||
function verifica() {
|
||||
if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab)
|
||||
var nudge = document.getElementById('nudge-trimiteri');
|
||||
if (!nudge || !nudge.hidden) return; // deja afisat -> nu re-cere
|
||||
fetch('/_fragments/trimiteri-versiune', { headers: { 'X-Requested-With': 'fetch' } })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(d) {
|
||||
if (!d) return;
|
||||
if (d.v !== versiuneCurenta()) nudge.hidden = false;
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
var d = evt.detail || {};
|
||||
if (!d.elt || d.elt.id !== 'submissions-wrap') return; // doar poll-ul listei
|
||||
var rc = d.requestConfig || {};
|
||||
if (rc.triggeringEvent) return; // trimiteriChanged / filtru: TREC MEREU
|
||||
if (modalDeschis() || existaBifa()) evt.preventDefault(); // pauza scopata pe periodic
|
||||
});
|
||||
// Resume pe checkbox `change`->gol: delegare pe body ca sa prinda si checkbox-urile
|
||||
// randate dupa swap. Cand modalul e inchis si nu mai exista nicio bifa, fortam un
|
||||
// refresh imediat (nu mai asteptam ticul de 15s) prin `trimiteriChanged from:body`,
|
||||
// care pastreaza filtrul curent (hx-include #filtre-trimiteri) si trece de guard.
|
||||
document.body.addEventListener('change', function(evt) {
|
||||
var t = evt.target;
|
||||
if (!(t && t.name === 'submission_id')) return;
|
||||
if (!modalDeschis() && !existaBifa() && window.htmx) {
|
||||
htmx.trigger(document.body, 'trimiteriChanged');
|
||||
}
|
||||
});
|
||||
setInterval(verifica, INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
{# 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 -->
|
||||
<!-- Bara de status: mereu vizibila -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status"
|
||||
hx-trigger="load, every 15s"
|
||||
@@ -12,80 +9,9 @@
|
||||
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab-bar: navigare intre sectiuni -->
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{# 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")
|
||||
] %}
|
||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
|
||||
<a id="{{ tab_elem_id }}"
|
||||
role="tab"
|
||||
href="/?tab={{ tab_id }}"
|
||||
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
|
||||
aria-controls="tab-panel"
|
||||
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
|
||||
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
|
||||
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
|
||||
hx-get="/_fragments/{{ tab_id }}"
|
||||
hx-target="#tab-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ badge }}</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
|
||||
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
|
||||
<!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
|
||||
<div id="tab-panel" class="tab-panel">
|
||||
{{ panel_html | safe }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* Navigare cu sageti intre tab-uri (ARIA pattern) — scoped pe fiecare tablist.
|
||||
Folosim querySelectorAll pentru a suporta multiple tablist-uri pe pagina
|
||||
(tab-bar principal + tab-urile interne din panoul Integrare). */
|
||||
document.querySelectorAll('[role="tablist"]').forEach(function(tablist) {
|
||||
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
||||
if (!tabs.length) return;
|
||||
|
||||
tablist.addEventListener('keydown', function(e) {
|
||||
var idx = tabs.indexOf(document.activeElement);
|
||||
if (idx === -1) return;
|
||||
var next = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
next = (idx + 1) % tabs.length;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
next = (idx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
next = 0;
|
||||
} else if (e.key === 'End') {
|
||||
next = tabs.length - 1;
|
||||
}
|
||||
if (next !== -1) {
|
||||
e.preventDefault();
|
||||
tabs[next].focus();
|
||||
}
|
||||
});
|
||||
|
||||
/* La click pe tab: actualizeaza aria-selected + tabindex (scoped pe tablist-ul curent) */
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) {
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
t.setAttribute('tabindex', '-1');
|
||||
t.classList.remove('tab-activ');
|
||||
});
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
tab.setAttribute('tabindex', '0');
|
||||
tab.classList.add('tab-activ');
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4).
|
||||
"""Worker RAR — proces propriu (NU task asyncio in uvicorn).
|
||||
|
||||
Bucla: heartbeat -> recupereaza orfane -> claim atomic -> login -> postPrezentare -> update.
|
||||
Ruleaza ca proces separat sub `restart: always` (docker compose).
|
||||
|
||||
T2 implementat:
|
||||
- claim atomic anti-race (BEGIN IMMEDIATE), respecta next_attempt_at (backoff).
|
||||
- reconciliere anti-duplicat pe raspuns pierdut: pe eroare tranzitorie/timeout SAU pe
|
||||
rand 'sending' orfan (worker mort mid-POST), interogheaza finalizate si match pe
|
||||
vin+dataPrestatie+odometruFinal; daca exista -> 'sent' (NU re-trimite).
|
||||
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error' (banner).
|
||||
- retry/backoff exponential pe erori tranzitorii; peste worker_max_retries -> 'error'.
|
||||
- lease/timeout pe randuri 'sending' orfane.
|
||||
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h.
|
||||
|
||||
Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE
|
||||
(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT
|
||||
(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul
|
||||
in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul
|
||||
re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata).
|
||||
Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc).
|
||||
Worker-ul face login per CONT cu acele creds, cache-uieste JWT (30h) in memorie si
|
||||
STERGE creds-urile contului dupa primul login reusit. Token-ul in memorie acopera
|
||||
restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul
|
||||
submission care aduce creds proaspete (degradare acceptata).
|
||||
Dev: `worker_use_test_creds` foloseste creds <test> cand submission-ul nu are enc.
|
||||
|
||||
Ce NU e inca: criptare PII payload at-rest (P2), b64Image mare pe disc (P2).
|
||||
|
||||
Pornire: python -m app.worker
|
||||
"""
|
||||
|
||||
@@ -61,8 +58,8 @@ def _iso(dt: datetime) -> str:
|
||||
|
||||
|
||||
def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None:
|
||||
"""Migrare print -> jurnal structurat (US-005): emite evenimentul (sursa=worker, dublu
|
||||
canal DB+fisier) SI pastreaza linia in stdout (operatorul tailuieste .run/worker.log)."""
|
||||
"""Emite evenimentul (sursa=worker, dublu canal DB+fisier) SI pastreaza linia in
|
||||
stdout (operatorul tailuieste .run/worker.log)."""
|
||||
print(f"[worker] {mesaj}", flush=True)
|
||||
log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context,
|
||||
conn=conn, sursa="worker")
|
||||
@@ -84,17 +81,17 @@ def _is_transient(exc: Exception) -> bool:
|
||||
|
||||
# --- Operatii pe submissions ---
|
||||
|
||||
# Stari blocate ne-sent care primesc retentie proprie (US-013). Mai scurta decat
|
||||
# cele 90z ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
|
||||
# Stari blocate ne-sent care primesc retentie proprie. Mai scurta decat cele 90z
|
||||
# ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita.
|
||||
_BLOCKED_STATES = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
|
||||
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
|
||||
if status == "sent":
|
||||
# T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
|
||||
# purge_after = sent + 90 zile (GDPR/L.142 retentie maxima).
|
||||
purge_expr = "datetime('now', '+90 days')"
|
||||
elif status in _BLOCKED_STATES:
|
||||
# US-013: randurile blocate primesc si ele purge_after (altfel raman permanent).
|
||||
# Randurile blocate primesc si ele purge_after (altfel raman permanent).
|
||||
days = int(get_settings().blocked_retention_days)
|
||||
purge_expr = f"datetime('now', '+{days} days')"
|
||||
else:
|
||||
@@ -114,15 +111,15 @@ def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_err
|
||||
)
|
||||
|
||||
|
||||
# T16: purge interval in secunde (odata pe ora, nu prea agresiv)
|
||||
# Purge interval in secunde (odata pe ora, nu prea agresiv)
|
||||
_PURGE_INTERVAL_S = 3600
|
||||
|
||||
|
||||
def purge_expired(conn) -> dict[str, int]:
|
||||
"""Sterge randurile expirate (purge_after < now).
|
||||
|
||||
T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping)
|
||||
expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
|
||||
Submissions `sent` SI blocate (error/needs_data/needs_mapping) expirate;
|
||||
import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
|
||||
EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar
|
||||
daca ar avea un purge_after rezidual; reactivarea il curata oricum).
|
||||
Intoarce {submissions_purged, batches_purged, events_purged}.
|
||||
@@ -174,7 +171,7 @@ 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 <= ?) "
|
||||
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
|
||||
# Gate pe stare de cont: 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",
|
||||
@@ -253,7 +250,7 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
|
||||
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
|
||||
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
|
||||
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
|
||||
# input va esua iar). Marcam 'error' cu mesajul real RAR. (Confirmat live 2026-06-23.)
|
||||
# input va esua iar). Marcam 'error' cu mesajul real RAR.
|
||||
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
|
||||
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
|
||||
@@ -363,7 +360,7 @@ class AccountSessions:
|
||||
token = rar.login(creds["email"], creds["password"])
|
||||
except RarAuthError as exc:
|
||||
rar.close()
|
||||
# US-005: login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
||||
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
||||
log_event("rar_login", nivel="WARNING", account_id=account_id,
|
||||
cod="RAR_CREDS_INVALIDE",
|
||||
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
|
||||
@@ -375,11 +372,11 @@ class AccountSessions:
|
||||
raise
|
||||
self._sessions[account_id] = (rar, token)
|
||||
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
|
||||
# US-005: login reusit (fara email/parola in clar — context curat).
|
||||
# Login reusit (fara email/parola in clar — context curat).
|
||||
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
|
||||
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
|
||||
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
|
||||
# GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
|
||||
# GATE PURJARE: sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
|
||||
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
|
||||
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
|
||||
conn.execute(
|
||||
@@ -418,7 +415,7 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
|
||||
|
||||
|
||||
def _creds_from_account(conn, account_id: int) -> dict | None:
|
||||
"""Fallback T1/D4: crede RAR durabile per-cont din accounts.rar_creds_enc.
|
||||
"""Fallback: crede RAR durabile per-cont din accounts.rar_creds_enc.
|
||||
|
||||
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
|
||||
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
|
||||
@@ -436,7 +433,7 @@ def run() -> int:
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
|
||||
settings = get_settings()
|
||||
set_source("worker") # US-005: evenimentele worker-ului au sursa=worker (fisier app-worker.log)
|
||||
set_source("worker") # evenimentele worker-ului au sursa=worker (fisier app-worker.log)
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
|
||||
@@ -448,7 +445,7 @@ def run() -> int:
|
||||
try:
|
||||
write_heartbeat(conn, detail=f"poll (queue={_queue_depth(conn)})")
|
||||
|
||||
# T16: purjare periodica (odata pe ora) — NU mai frecvent.
|
||||
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
||||
now_ts = time.time()
|
||||
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
||||
stats = purge_expired(conn)
|
||||
@@ -474,20 +471,20 @@ def run() -> int:
|
||||
|
||||
sid = claimed["id"]
|
||||
account_id = claimed["account_id"]
|
||||
# T1/US-012: randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima
|
||||
# trimitere a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea
|
||||
# RAR cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
|
||||
# Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
|
||||
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
|
||||
# cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
|
||||
# ignorand corectia. Re-login imediat cu creds-urile noi.
|
||||
if claimed.get("creds_enc"):
|
||||
sessions.invalidate(account_id)
|
||||
# T1/D4: incearca creds din submission (canal API efemer), cu fallback la
|
||||
# Incearca creds din submission (canal API efemer), cu fallback la
|
||||
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
|
||||
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
|
||||
|
||||
try:
|
||||
token = sessions.get_token(conn, account_id, creds)
|
||||
except RarAuthError as exc:
|
||||
# Creds gresite (login 401): NU se face retry (plan, failure registry).
|
||||
# Creds gresite (login 401): NU se face retry.
|
||||
mark(conn, sid, "error", rar_status_code=401,
|
||||
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False))
|
||||
# rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului.
|
||||
|
||||
@@ -9,13 +9,11 @@ services:
|
||||
api:
|
||||
build: .
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8010
|
||||
ports:
|
||||
- "8010:8010"
|
||||
volumes:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
AUTOPASS_RAR_ENV: test
|
||||
AUTOPASS_RAR_ENV: prod
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
|
||||
restart: always
|
||||
@@ -35,7 +33,7 @@ services:
|
||||
AUTOPASS_RAR_ENV: test
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
||||
AUTOPASS_WORKER_SEND_ENABLED: "false"
|
||||
AUTOPASS_WORKER_SEND_ENABLED: "true"
|
||||
restart: always
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
File diff suppressed because one or more lines are too long
430
docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md
Normal file
430
docs/prd/prd-5.10-ux-filtre-pill-paginare-mapari-meniu.md
Normal file
@@ -0,0 +1,430 @@
|
||||
<!-- /autoplan restore point: ~/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260625-120049.md -->
|
||||
# PRD 5.10 — UX trimiteri (pill filtre, paginare, detaliu) + Mapari in meniu
|
||||
|
||||
**Stare**: inchis (2026-06-25 — 14 stories + fix US-006b TDD prin echipa; VERIFY PASS; `/code-review high` 1 finding material reparat; regresie 896 passed / 1 skipped / 0 failed; fonturi IBM Plex reale)
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Curatare UX a dashboard-ului pe doua zone: (a) **tabelul de trimiteri** — pill-uri de filtrare
|
||||
pe categorii de problema in locul listei expandate de ID-uri, filtrare pe interval de data care
|
||||
chiar functioneaza pe timestamp-uri, paginare numerotata, VIN sub numarul de inmatriculare,
|
||||
editarea operatiei RAR si afisarea operatiei de service in detaliu, plus erori mai putin verbose
|
||||
in formularul de editare; (b) **Mapari** — mutata in meniul hamburger (fara tab-uri pe pagina
|
||||
principala), o singura pagina cu sectiunile consolidate si butoane de salvare/stergere vizibile.
|
||||
|
||||
Pur UI/UX + un fix de filtrare backend. Backend-ul de trimitere (worker, masina de stari,
|
||||
idempotenta, mapping-rezolvare) ramane **NEATINS**.
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **Nu** atingem worker-ul, masina de stari, idempotenta sau logica de mapare operatie→cod.
|
||||
- **Nu** permitem editarea operatiei RAR pe stari trimise/in curs (`sent`/`sending`/`queued`) —
|
||||
la RAR `FINALIZATA` e terminal; editarea apare doar pe `needs_data`/`needs_mapping`/`error`.
|
||||
- **Nu** schimbam paginarea client-side existenta din tabelele Mapari (datatable `data-dt`).
|
||||
- **Nu** schimbam mecanismul de persistenta/anti-FOUC al temei (PRD 5.3) — il extindem cu teme noi, nu il rescriem.
|
||||
- **Nu** adaugam filtre noi (doar pill-uri pe categoriile de problema deja existente + fix data).
|
||||
- **Nu** modificam contractul API `POST /v1/prezentari` / `GET /v1/prezentari` (operatia de
|
||||
service afisata vine din payload-ul deja stocat, nu cere camp nou).
|
||||
- **Nu** reproiectam meniul hamburger in sine — doar adaugam intrarea „Mapari" si scoatem tab-bar-ul.
|
||||
- **Acceptat explicit (review C4)**: doua idiomuri de paginare coexista — server-side numerotat pe Trimiteri (US-004) vs client-side `data-dt` (Inapoi/Inainte) pe Mapari. Diferenta e intentionata, nu „de reparat" ulterior.
|
||||
- **Acceptat explicit (review C2)**: butonul ciclic de tema (US-014) are cost de descoperire (4 teme fara optiuni vizibile). Compensat prin `aria-label` curenta+urmatoarea + tooltip; nu trecem la meniu/popover in aceasta livrabila.
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
|
||||
> comportament = 2 stories.
|
||||
|
||||
### US-001: Fix filtrare pe interval de data (backend)
|
||||
**Ca** operator **vreau** ca filtrul „Data de la / pana la" sa returneze toate trimiterile din
|
||||
acea perioada **pentru ca** acum, fiindca `data_prestatie` poate avea ora/minut/secunda,
|
||||
comparatia de string exclude randurile si tabelul apare gol.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`, `_is_iso_date`), `tests/test_web_filtre_submissions.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_filtre_submissions.py` — `test_filtru_data_include_timestamp_cu_ora`, `test_filtru_data_interval_inclusiv_capete`, `test_filtru_data_ignora_valori_ne_data`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **(must-fix, review C1)** Cauza reala = garda `_is_iso_date` cere `len(s)==10` (`routes.py:640-649`), deci orice `data_prestatie` cu ora e **exclusa inainte** de comparatie. Fix: introdu `_iso_date_prefix(value) -> str | None` (intoarce `value[:10]` daca parseaza ca `YYYY-MM-DD`, altfel `None`) si foloseste-l **atat** pentru garda **cat si** pentru comparatie. Nu modifica doar liniile de comparatie — altfel bug-ul ramane.
|
||||
- [ ] Filtrarea compara doar **portiunea de data** (primele 10 caractere, `YYYY-MM-DD`) a lui `data_prestatie`, chiar daca valoarea contine ora/minut/secunda (ex. `2026-06-20 14:35:07` sau `2026-06-20T14:35:07`).
|
||||
- [ ] Intervalul e **inclusiv la ambele capete**: `data_de <= data(rand) <= data_pana`.
|
||||
- [ ] O singura limita (doar `data_de` sau doar `data_pana`) functioneaza corect.
|
||||
- [ ] Valorile care nu incep cu o data ISO valida raman excluse din filtru (comportament actual pastrat).
|
||||
- [ ] `python3 -m pytest tests/test_web_filtre_submissions.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — import/seed cu o trimitere cu `data_prestatie` ce contine ora; filtru pe acea zi → randul apare.
|
||||
|
||||
### US-002: Expune operatia de service in view-ul de rand si detaliu (backend)
|
||||
**Ca** operator **vreau** sa vad operatia de service originala (codul intern / denumirea venita
|
||||
prin API sau import CSV) **pentru ca** vreau sa stiu ce a cerut service-ul, nu doar codul RAR mapat.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/payload_view.py` (sau `app/web/routes.py` `_detaliu_ctx`/`prezentare_din_payload`), `tests/test_payload_view.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_payload_view.py` — `test_operatie_service_din_cod_op_service`, `test_operatie_service_din_denumire`, `test_fara_operatie_service_cand_lipseste`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **(must-fix, review M1)** `prezentare_din_payload` colapseaza acum service-op si RAR-op in chei suprapuse (`operatie = denumire or cod`, `cod_rar = cod_prestatie`; `payload_view.py:111-113`). Adauga **chei noi distincte** (ex. `op_service_cod` + `op_service_denumire`) din `cod_op_service`/`denumire`, separate de operatia RAR mapata.
|
||||
- [ ] **Conventie goala (must-fix, review M1)**: payload_view emite `EMPTY="—"` pentru valori lipsa (`payload_view.py:119-127`), dar US-007 cere „randul nu apare deloc (fara — gol)". Alege O conventie: fie campul nou intoarce `""`/`None` (nu `—`), fie template-ul testeaza fata de `'—'` (ca randul VIN, `_submissions.html:63`). Documenteaz-o aici si in US-007.
|
||||
- [ ] Cand payload-ul nu contine operatie de service (a venit direct cu `cod_prestatie`), campul e gol conform conventiei de mai sus, fara a arunca.
|
||||
- [ ] Helper-ul ramane pur (fara DB), defensiv la payload lipsa/corupt.
|
||||
- [ ] `python3 -m pytest tests/test_payload_view.py -q` trece.
|
||||
- **Verificare E2E**: `POST /v1/prezentari` cu `cod_op_service`+`denumire` → randul are operatia de service in context.
|
||||
|
||||
### US-003: Pill-uri de filtrare pe categorii de problema (UI)
|
||||
**Ca** operator **vreau** pill-uri „Date incomplete / Lipsa cod / Eroare" cu numar in sectiunea de
|
||||
filtrare, in loc de lista expandata `#40 TMB...3456 / B28ERR ...si inca 3` **pentru ca** lista de
|
||||
ID-uri e zgomotoasa; vreau sa apas un pill ca sa filtrez direct pe acea categorie.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_coada.html`, `app/web/templates/_status.html`, `app/web/routes.py` (counts per categorie pe fragmentul de filtre), `tests/test_web_pill_filtre.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_pill_filtre.py` — `test_pill_per_categorie_cu_numar`, `test_pill_click_seteaza_status`, `test_fara_lista_id_uri`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] In sectiunea de filtrare apar pill-uri: **Date incomplete** (`needs_data`), **Lipsa cod** (`needs_mapping`), **Eroare** (`error`), fiecare cu numarul total scoped pe cont; pill-ul lipseste/e ascuns cand numarul e 0.
|
||||
- [ ] **(must-fix, review M3/design-H1)** Lista de ID-uri si blocul `blocate_actionabil` sunt in **`_status.html:68-77`** (nu `_coada.html`), generate de `_blocate_actionabil` (`routes.py:562-594`). La eliminarea ID-urilor, scoate si codul mort care calcula `prezentare_din_payload`/`vin_partial` per rand. Pill-urile reutilizeaza contoarele deja calculate (`_status_counts`/`n`) — fara filtru backend nou.
|
||||
- [ ] **(must-fix, review design-M1)** Decide explicit soarta intregului bloc „Necesita atentia ta" + a contorului „Blocate": pill-urile **inlocuiesc** link-urile de categorie (nu triplu-encoda aceeasi informatie cu pill + link + dropdown).
|
||||
- [ ] **(must-fix, review M3/A3)** Pill-urile sunt elemente focalizabile reale (`<button>`/`<a>`, nu `<span onclick>`), cu `focus-visible`, activare Enter/Space, si stare activa via `aria-pressed` (nu doar culoare).
|
||||
- [ ] Click pe un pill filtreaza tabelul pe acea stare (seteaza `status=` pe `fragment_submissions`, deja suportat `routes.py:699`); pill activ evidentiat; click pe activ revine la „toate".
|
||||
- [ ] **(must-fix, review M3)** Sincronizare cu dropdown-ul `status` din `_coada.html:36-44`: ori dropdown-ul reflecta pill-ul activ, ori e eliminat — fara desync (click pill vs valoare dropdown stale).
|
||||
- [ ] Exista si un control „Toate" (toggle) care reseteaza filtrul de categorie. Stare „toate zero" (steady-state sanatos): definita explicit (rand ascuns vs afisaj muted „nicio problema"), nu gap neexplicat.
|
||||
- [ ] **(must-fix, review S1/A5)** Matrice de stil pill rezolvata fara contradictie rosu/galben vs accent: inactiv = contur/text pe culoarea categoriei (`--err`/`--warn`); activ = umplere pe **culoarea categoriei** (NU accent albastru — altfel pill rosu „Date incomplete" devine albastru cand e activ). Contrast text pe pill-ul activ verificat AA in toate cele 3 teme.
|
||||
- [ ] `python3 -m pytest tests/test_web_pill_filtre.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — pill-uri cu numere; click pe „Date incomplete" → tabel filtrat; click din nou → toate.
|
||||
|
||||
### US-004: Paginare numerotata pe tabelul de trimiteri (backend + UI)
|
||||
**Ca** operator **vreau** paginare numerotata pe tabelul de trimiteri **pentru ca** acum se
|
||||
incarca max 200 randuri fara navigare si nu pot ajunge la cele mai vechi.
|
||||
|
||||
- **Depinde de**: US-001 (filtrul de data corect intra in acelasi handler)
|
||||
- **Fisiere**: `app/web/routes.py` (`fragment_submissions`: param `page`, total + slice ramificat — vezi C1), `app/web/templates/_submissions.html`, `app/web/templates/_coada.html` (poll `hx-include` + `page`), `tests/test_web_paginare_submissions.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_paginare_submissions.py` — `test_pagina_implicita_25`, `test_pagina_2_offset`, `test_total_si_numar_pagini`, `test_paginarea_pastreaza_filtrele`, `test_pagina_peste_total_revine_la_ultima`, `test_poll_pastreaza_pagina`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **(must-fix, review H1)** Numararea totalului ramifica dupa tipul de filtru: **fara** filtru Python (status-only / niciun filtru) → SQL `COUNT(*)` + `LIMIT 25 OFFSET (page-1)*25`; **cu** filtru vehicul/data activ → fetch-all (fara LIMIT, ca azi) → filtreaza in Python → `total=len(filtrat)` → slice `[offset:offset+25]`. SQL `COUNT(*)/LIMIT/OFFSET` e **gresit** cand filtrul Python e activ. Plafonul de 200 randuri din bucla se inlocuieste cu „fetch-all-then-slice" pe calea filtrata (altfel paginile >8 dispar silentios).
|
||||
- [ ] Marime pagina fixa **25** randuri; raspunsul include numarul total si pagina curenta.
|
||||
- [ ] **(must-fix, review H2)** `page` in afara intervalului se clampeaza la `[1, ceil(total/25)]` (nu pagina goala); schimbarea unui filtru reseteaza `page` la 1.
|
||||
- [ ] Controale: `‹ Inapoi`, numere de pagina, `Inainte ›`; pagina curenta evidentiata cu `aria-current="page"`; capetele dezactivate (`disabled`) la prima/ultima pagina; pager-ul e **ascuns** cand `pages<=1` sau `total==0` (empty state inlocuieste tabelul). „afiseaza X-Y din N" intr-o regiune `aria-live="polite"`.
|
||||
- [ ] Schimbarea paginii **pastreaza** filtrele active (status/pill, vehicul, data_de, data_pana) — link-urile de paginare poarta toti parametrii de filtru curenti.
|
||||
- [ ] **(must-fix, review L2)** Poll-ul de 15s (`_coada.html` `hx-include="#filtre-trimiteri"`) NU trebuie sa reseteze pagina: include `page` curent in include-ul de poll (hidden input actualizat de paginare) SAU documenteaza explicit reset-pe-poll. Test: `test_poll_pastreaza_pagina`.
|
||||
- [ ] `python3 -m pytest tests/test_web_paginare_submissions.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` cu >25 trimiteri — navigare intre pagini, filtru aplicat ramane la schimbarea paginii, poll-ul nu te scoate de pe pagina curenta.
|
||||
|
||||
### US-005: VIN sub numarul de inmatriculare in tabel (UI)
|
||||
**Ca** operator **vreau** ca VIN-ul sa apara pe rand propriu sub numarul de inmatriculare **pentru
|
||||
ca** pe aceeasi linie e ingramadit si greu de citit.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_submissions.html`, `tests/test_web_submissions_layout.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_submissions_layout.py` — `test_vin_pe_rand_separat_sub_nr`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **(nota, review L1)** VIN-ul e deja randat in `_submissions.html:61-65`, dar **inline** (`<span>` dupa nr, aceeasi celula). Schimbarea e mica: element block sub nr (nu structura noua). Testul asserteaza un element **block-level**, nu doar prezenta.
|
||||
- [ ] In coloana Vehicul, numarul de inmatriculare e pe primul rand; VIN-ul (sau partiala VIN) apare **dedesubt**, in stil muted, nu pe aceeasi linie.
|
||||
- [ ] Cand VIN-ul lipseste, nu apare rand gol (garda `!= '—'` exista deja, `_submissions.html:63`).
|
||||
- [ ] Layout-ul ramane fara scroll orizontal pe tabel (scopat `.tabel-trimiteri`, consistent cu PRD 5.8).
|
||||
- [ ] `python3 -m pytest tests/test_web_submissions_layout.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — VIN sub numar in fiecare rand.
|
||||
|
||||
### US-006: Editare operatie RAR in formularul de detaliu (UI + backend corectie)
|
||||
**Ca** operator **vreau** sa pot schimba operatia RAR (`cod_prestatie`) din formularul de detaliu pe
|
||||
trimiterile blocate **pentru ca** uneori codul mapat e gresit si vreau sa-l corectez inainte de re-trimitere.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `app/web/routes.py` (`post_corectie_trimitere` ~979-1124), `tests/test_web_editare_op_rar.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_editare_op_rar.py` — `test_editabil_arata_select_cod_rar`, `test_salvare_schimba_cod_si_repune_in_coada`, `test_idempotency_key_se_schimba`, `test_cod_invalid_respins`, `test_sent_nu_arata_select`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Pe `needs_data`/`needs_mapping`/`error`, formularul afiseaza un **select cu codurile din nomenclator** pentru operatia RAR, pre-selectat pe codul curent.
|
||||
- [ ] **(must-fix, review H3)** Riscul „refoloseste exact calea existenta" e GRESIT formulat: `post_corectie_trimitere` aplica DOAR campurile vehiculului (`vin/nr/data/odometru`), nu citeste niciun cod de prestatie (`routes.py:1009-1012`). Story-ul cere logica **noua** in handler: (a) camp nou `cod_prestatie` in form; (b) validare fata de nomenclator (oglindeste `routes.py:953-961`); (c) injectare in `content["prestatii"][i]["cod_prestatie"]` inainte de `resolve_prestatii`. DUPA injectare, restul caii existente (canonicalize → `build_key` → check coliziune idempotency → re-queue, `routes.py:1028-1104`) recalculeaza corect payload+cheie (`build_key` hashuieste `cod_prestatie`, `idempotency.py:34`).
|
||||
- [ ] Codul nou e validat fata de nomenclator; un cod necunoscut e **respins** (nu se injecteaza, nu se trimite raw — vezi invariantul `cod_prestatie` validat la ingestie din CLAUDE.md).
|
||||
- [ ] Test `test_idempotency_key_se_schimba`: cheia de idempotency **difera** dupa schimbarea codului (nu doar `status=queued`).
|
||||
- [ ] Daca payload-ul are mai multe prestatii, story-ul tinteste prima/operatia editabila explicit (nu ambiguu).
|
||||
- [ ] Pe `sent`/`sending`/`queued` operatia RAR ramane **read-only** (fara select).
|
||||
- [ ] Scoped pe sesiune + CSRF, 404 cross-account.
|
||||
- [ ] `python3 -m pytest tests/test_web_editare_op_rar.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — pe o trimitere `needs_data`, schimba codul RAR din select, salveaza → cod nou aplicat + cheie idempotency noua; cod invalid → respins; pe o trimitere `sent`, codul e read-only.
|
||||
|
||||
### US-007: Afisare operatie de service in detaliu (UI)
|
||||
**Ca** operator **vreau** sa vad operatia de service originala in formularul de detaliu **pentru
|
||||
ca** vreau sa stiu ce a cerut service-ul prin API/CSV, alaturi de codul RAR.
|
||||
|
||||
- **Depinde de**: US-002
|
||||
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html`, `tests/test_web_detaliu_op_service.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_detaliu_op_service.py` — `test_detaliu_arata_operatie_service`, `test_detaliu_omite_cand_lipseste`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] In detaliu apare „Operatie service" (cod intern + denumire) cand a existat in payload, distinct de „Operatie RAR".
|
||||
- [ ] Cand operatia de service lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc (fara „—" gol).
|
||||
- [ ] Apare atat in contextul editabil cat si in cel read-only.
|
||||
- [ ] `python3 -m pytest tests/test_web_detaliu_op_service.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — trimitere venita cu `cod_op_service` arata operatia de service in detaliu; una venita cu `cod_prestatie` direct nu o arata.
|
||||
|
||||
### US-008: Simplificare eroare in formularul de editare (UI)
|
||||
**Ca** operator **vreau** ca in formularul de editare sa apara doar textul simplu al erorii (subliniat),
|
||||
nu blocul verbose pe 3 niveluri **pentru ca** „De ce / Cum repari" + prefixul tehnic dubleaza un
|
||||
mesaj deja descriptiv (ex. „odometruFinal trebuie sa fie un numar intreg (ca string).").
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/_trimitere_detaliu.html` (zona editabila), `tests/test_web_detaliu_eroare_simpla.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_detaliu_eroare_simpla.py` — `test_form_editare_fara_card_3niveluri`, `test_eroare_pe_camp_doar_text_simplu`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] **(must-fix, review M4)** Blocul `erori_3n`/`card_erori` e acum randat **inainte** de form, in afara ramurii `{% if editabil %}` (`_trimitere_detaliu.html:22-27`) — deci apare si pe read-only. Ascunde-l DOAR in editare: muta-l/infasoara-l in `{% if not editabil %}` ca sa-l pastrezi pe contextele read-only (catalogul PRD 5.4).
|
||||
- [ ] Eroarea per camp ramane sub input ca **text simplu subliniat** (mesajul descriptiv). Nota: macro-ul `camp` printeaza deja doar `err_map.get(nume)` (mesajul, fara cod de camp) — verifica doar ca `message` nu inglobeaza numele campului.
|
||||
- [ ] **(must-fix, review M6/design)** Erorile **fara camp** (`field is None`) nu trebuie sa dispara silentios cand scoatem cardul 3n: defineste unde apar (ex. un rezumat simplu top-of-form) in formularul de editare.
|
||||
- [ ] Restul contextelor (lista compacta / detaliu read-only) raman neschimbate — simplificarea e scopata doar pe formularul de editare.
|
||||
- [ ] `python3 -m pytest tests/test_web_detaliu_eroare_simpla.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — corectie cu odometru invalid → sub camp apare doar textul erorii subliniat, fara cardul pe 3 niveluri.
|
||||
|
||||
### US-009: Mapari in meniul hamburger + scoatere tab-uri (UI)
|
||||
**Ca** operator **vreau** ca Mapari sa fie o intrare in meniul hamburger, nu tab pe pagina
|
||||
principala **pentru ca** vreau pagina principala curata (doar Acasa), iar tab-bar-ul Acasa/Mapari
|
||||
incurca.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/base.html` (meniu `#cont-menu`), `app/web/templates/dashboard.html` (tab-bar), `app/web/routes.py` (rutare pagina Mapari, ex. `/?tab=mapari` sau `/mapari`), `tests/test_web_mapari_meniu.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_mapari_meniu.py` — `test_meniu_contine_mapari`, `test_pagina_principala_fara_tabbar_mapari`, `test_ruta_mapari_randeaza_sectiunea`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Meniul hamburger contine o intrare **Mapari** (cu badge pentru `needs_mapping` daca exista contoare). Badge-ul muta din tab-bar pe item-ul de meniu — sursa contorului ramane `badges.mapari` (`routes.py:423-424`); verifica sa nu ramana badge dangling.
|
||||
- [ ] Tab-bar-ul Acasa/Mapari de pe pagina principala e **eliminat**; Acasa devine continutul principal direct.
|
||||
- [ ] **(must-fix, review design-C1)** Scoate si schela ARIA `role="tablist"` orfana + JS-ul de navigare cu sageti din `dashboard.html:16-88` (un `role="tablist"` cu un singur tab e o violare ARIA). Curata plumbing-ul `active_tab` ramas fara sens daca nu mai e folosit.
|
||||
- [ ] Exista o ruta dedicata care randeaza pagina Mapari (server-side, deep-link). `?tab=mapari` ramane valid (`_TABS_VALIDE` include `mapari`, `routes.py:155`; randat de `_render_panel_for_tab`) — nu da 404. Verifica sa nu ramana `hx-get` catre un element de tab eliminat (banner/badge).
|
||||
- [ ] `python3 -m pytest tests/test_web_mapari_meniu.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — fara tab-bar; din ☰ → Mapari → pagina Mapari.
|
||||
|
||||
### US-010: Restructurare pagina Mapari intr-o singura pagina consolidata (UI)
|
||||
**Ca** operator **vreau** o singura pagina Mapari cu „De rezolvat" prima, apoi salvate / reguli text /
|
||||
formate coloane, fara sectiunea de ajutor si fara textul gol **pentru ca** sectiunile separate si
|
||||
textele de ajutor ma incurca.
|
||||
|
||||
- **Depinde de**: US-009
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari_layout.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_mapari_layout.py` — `test_de_rezolvat_prima`, `test_fara_ajutor_si_empty_text`, `test_ordine_sectiuni`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Ordinea sectiunilor: (1) **De rezolvat** prima, (2) Mapari operatii salvate, (3) Reguli automate (text), (4) Formate de coloane salvate.
|
||||
- [ ] Sectiunea de ajutor (`<details class="ajutor-mapari">`) e **eliminata**.
|
||||
- [ ] Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. Importa un fisier nou..." e **eliminat** (sectiunea „De rezolvat" goala nu mai afiseaza acel paragraf).
|
||||
- [ ] Fiecare rand din „De rezolvat" pastreaza select-ul + butonul de salvare vizibil (vezi US-011).
|
||||
- [ ] `python3 -m pytest tests/test_web_mapari_layout.py -q` trece.
|
||||
- **Verificare E2E**: browser pe pagina Mapari — ordine corecta, fara ajutor, fara empty-text.
|
||||
|
||||
### US-012: Branding header „by ROMFAST" + titlu centrat (UI)
|
||||
**Ca** utilizator **vreau** ca header-ul sa aiba branding-ul „by ROMFAST" si titlul pe mijloc
|
||||
**pentru ca** vreau identitate vizuala clara, parte din familia ROMFAST/ROA, nu un header anonim.
|
||||
|
||||
> Sistem de design complet: `DESIGN.md` (sectiunile „Header & branding" + „Culori de brand").
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/templates/base.html` (header + CSS), `tests/test_web_header_branding.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_header_branding.py` — `test_header_contine_by_romfast`, `test_titlu_centrat`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Titlul „Gateway RAR AUTOPASS" e **centrat** in header (grila 3 coloane: controale — tema, versiune, ☰ — la **dreapta**, fara a strica centrarea optica a titlului).
|
||||
- [ ] **(must-fix, review S2)** Plaseaza explicit badge-ul `env` (test/prod, azi `base.html:320`) in grila — celula stanga (echilibru) sau langa titlu — si defineste ordinea de colaps pe mobil (3-col cu titlu centrat + controale care wrap e fragil).
|
||||
- [ ] Sub titlu, mic: wordmark **„by ROMFAST"** redat ca text stilizat — `by` in `--muted`, `ROM` in `#D1342F` (rosu logo), `FAST` in `#2E74D6` (albastru logo). **NU** se foloseste PNG-ul 3D al logo-ului.
|
||||
- [ ] Responsiv: pe mobil wordmark-ul ramane sub titlu, controalele nu se suprapun (degrada elegant).
|
||||
- [ ] Light + dark OK (wordmark pe culori proprii, lizibil pe ambele fundaluri).
|
||||
- [ ] `python3 -m pytest tests/test_web_header_branding.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` desktop + mobil — titlu centrat, „by ROMFAST" cu ROM rosu / FAST albastru, controale la dreapta.
|
||||
|
||||
### US-013: Tema de culori ROMFAST + tipografie (UI)
|
||||
**Ca** utilizator **vreau** o paleta cu accent albastru ROMFAST (ca romfast.ro) si o tipografie
|
||||
coerenta **pentru ca** acum totul e gri si fara identitate, iar produsul e parte din familia ROA.
|
||||
|
||||
> Valori complete: `DESIGN.md` (sectiunile „Decizie cromatica" + „Tipografie").
|
||||
|
||||
- **Depinde de**: design-consultation (DONE — `DESIGN.md` scris)
|
||||
- **Fisiere**: `app/web/templates/base.html` (variabile `:root` + `[data-theme="light"]` + `font-family`), `app/web/static/fonts/` (woff2 IBM Plex), `tests/test_web_tema_culori.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_tema_culori.py` — `test_paleta_accent_azur_definita`, `test_font_ibm_plex_aplicat`, `test_contrast_aa_pe_text_principal`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Accentul devine **azur ROMFAST**: `--accent:#2E74D6` (dark) / `#1F66C9` (light), aplicat prin variabile CSS (butoane primare, pill activ, linkuri, focus) — fara culori noi hardcodate imprastiate.
|
||||
- [ ] Neutrele actualizate conform `DESIGN.md` (dark `--bg:#0f1218`/`--card:#181c24`; light `--bg:#f5f7fa`/`--card:#ffffff`); stari `--ok/--warn/--err` pastrate AA per tema.
|
||||
- [ ] **Tipografie**: `font-family` UI = IBM Plex Sans (fallback `system-ui`); monospace (coduri RAR/VIN/nr.) = IBM Plex Mono. Self-host woff2 cu `font-display:swap` (subset latin-ext pentru diacritice). Fallback de sistem nu strica layout-ul.
|
||||
- [ ] **(must-fix, review M3/design)** `font-display:swap` produce un FOUT system-ui→IBM Plex; pe coloanele tabulare (VIN/coduri, `tabular-nums` `base.html:54`) o nepotrivire de metrici da reflow. Defineste fallback cu metrici ajustate (`size-adjust`/`ascent-override`) SAU accepta explicit FOUT-ul si confirma ca tabularele nu fac reflow vizibil.
|
||||
- [ ] Contrastul textului principal ramane **AA** in ambele teme; accentul-ca-text pe alb foloseste varianta inchisa (`#1F66C9`).
|
||||
- [ ] Comutatorul light/dark existent (PRD 5.3) + anti-FOUC functioneaza in continuare.
|
||||
- [ ] `python3 -m pytest tests/test_web_tema_culori.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — accent azur + IBM Plex in light si dark, pill-uri rotunjite ca pe romfast.ro, contrast verificat.
|
||||
|
||||
### US-014: Selector de tema ciclic (Light/Dark/Petrol/Auto) (UI)
|
||||
**Ca** utilizator **vreau** ca butonul de tema sa cicleze prin mai multe teme (inclusiv una petrol)
|
||||
**pentru ca** vreau sa-mi aleg aspectul, nu doar light/dark, ca pe demoanaf.ro.
|
||||
|
||||
> Spec complet: `DESIGN.md` (sectiunile „Selector de tema" + paleta „Petrol").
|
||||
|
||||
- **Depinde de**: US-013 (paleta azur + variabilele de baza)
|
||||
- **Fisiere**: `app/web/templates/base.html` (script anti-FOUC + `[data-theme="petrol"]` + JS comutator + buton), `tests/test_web_selector_tema.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_selector_tema.py` — `test_petrol_theme_definit`, `test_buton_cicleaza_temele`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Butonul de tema **cicleaza** la click: Light → Dark → Petrol → Auto → Light; iconita + `aria-label`/`title` reflecta tema curenta.
|
||||
- [ ] **(must-fix, review A2/design)** Buton accesibil: `aria-label` anunta curenta + urmatoarea („Tema: Petrol, apasa pentru Auto"); schimbarea anuntata via regiune `aria-live="polite"`. Tooltip enumera ciclul. Cost de descoperire (4 teme intr-un buton fara optiuni vizibile) acceptat explicit in Non-Goals.
|
||||
- [ ] Tema noua **Petrol** definita ca `[data-theme="petrol"]` cu valorile din `DESIGN.md` (accent `--accent:#0E7C7B`, neutre petrol-inchise).
|
||||
- [ ] **Auto** urmeaza `prefers-color-scheme` (rezolva la Light/Dark azur); nu seteaza `data-theme` fix.
|
||||
- [ ] **(must-fix, review M4/design)** Scriptul anti-FOUC din `<head>` (`base.html:19-29`) cunoaste azi doar `light`/`dark`. Extinde-l atomic sa enumere toate cele 4 stari; o valoare `localStorage.theme` **legacy/necunoscuta** are fallback definit (nu blink, nu stare invalida). Rezolva „Auto" la light/dark inainte de primul paint.
|
||||
- [ ] **(must-fix, review S3/design)** Wordmark-ul „FAST" `#2E74D6` (albastru) coexista pe ecranul Petrol cu accentul teal `#0E7C7B` — verifica armonia/contrastul wordmark-ului (ROM rosu + FAST albastru) pe **toate cele 3 teme** concrete, nu doar light/dark.
|
||||
- [ ] Toate suprafetele raman lizibile (AA pe text principal) in fiecare din cele 3 teme concrete.
|
||||
- [ ] `python3 -m pytest tests/test_web_selector_tema.py -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — click pe buton cicleaza Light→Dark→Petrol→Auto; refresh pastreaza tema; Petrol are accent teal, fara blink la load.
|
||||
|
||||
### US-011: Butoane icon salvare/stergere vizibile + evidentiere modificari nesalvate (UI)
|
||||
**Ca** operator **vreau** butoane mici cu icon de salvare/stergere mereu vizibile pe randurile de
|
||||
mapari, evidentiate cand am modificari nesalvate **pentru ca** acum trebuie sa intru intr-un meniu
|
||||
contextual (kebab) si nu imi dau seama ca trebuie sa apas „Salveaza".
|
||||
|
||||
- **Depinde de**: US-010
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `app/web/templates/base.html` (CSS/JS mic pentru stare „dirty"), `tests/test_web_mapari_actiuni.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `tests/test_web_mapari_actiuni.py` — `test_butoane_icon_vizibile_pe_rand`, `test_fara_kebab_menu`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Pe „Mapari operatii salvate" si „Reguli automate (text)", actiunile Salveaza/Sterge sunt **butoane mici cu icon mereu vizibile** pe rand, nu ascunse in meniu kebab.
|
||||
- [ ] **(must-fix, review A1 — decizie de taste, vezi poarta)** Icon-urile sunt **SVG/text stilizate ca icon** (reuse `.icon-btn`), NU emoji brute 💾/🗑: emoji-ul nu se recoloreaza pe teme/dirty-state si randeaza inconsistent intre OS-uri. Numele accesibil din `aria-label`; glifa decorativa `aria-hidden`.
|
||||
- [ ] Meniul kebab (`position:fixed`) e **eliminat**.
|
||||
- [ ] **(must-fix, review S4)** Cand utilizatorul schimba select-ul unui rand, butonul de salvare devine **evidentiat** concret (ex. fundal `--accent` + nu emoji) ca semnal de „modificari nesalvate"; in starea normala e discret. Starea „dirty" e efemera per-render (un swap `outerHTML` o reseteaza — fara persistenta asteptata).
|
||||
- [ ] Confirmarea la stergere (`hx-confirm`) se pastreaza.
|
||||
- [ ] **(must-fix, review A6)** Accesibil: butoanele au `aria-label` descriptiv; pe mobil regula 44px e scopata ca icon-urile sa ramana **icon-size** (nu full-width ca butoanele normale `min-height:44px;width:100%`), cu zona de atingere adecvata.
|
||||
- [ ] `python3 -m pytest tests/test_web_mapari_actiuni.py -q` trece.
|
||||
- **Verificare E2E**: browser pe pagina Mapari — schimba un cod RAR la o mapare salvata → butonul de salvare se evidentiaza; salveaza/sterge din icon-uri vizibile.
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Filtrarea pe data (US-001)**: `data_prestatie` poate veni in formate diferite (ISO cu/ fara ora,
|
||||
`T` vs spatiu). Mitigare: normalizeaza la primele 10 caractere doar cand sunt o data ISO valida;
|
||||
pastreaza excluderea valorilor ne-ISO (comportament existent) + test parametrizat pe formate.
|
||||
- **Paginare + filtru in Python (US-004)**: filtrele vehicul/data se aplica post-SQL; numararea
|
||||
totalului trebuie sa respecte filtrul, nu doar `COUNT(*)` SQL. Mitigare: numara dupa filtrare la
|
||||
scara actuala (plafon perf deja notat in cod) si testeaza `total` cu filtru activ.
|
||||
- **Editare operatie RAR (US-006)**: schimbarea codului trebuie sa recalculeze payload + idempotency
|
||||
ca la corectia `needs_data` existenta, altfel risca chei divergente. Mitigare: refoloseste exact
|
||||
calea de corectie/mapare existenta, nu una noua.
|
||||
- **Mutare Mapari din tab (US-009)**: deep-link-uri vechi `?tab=mapari` pot exista. Mitigare:
|
||||
alias/redirect, test ca nu da 404.
|
||||
- **Regresie pe contexte de eroare (US-008)**: simplificarea trebuie scopata DOAR pe formularul de
|
||||
editare; lista compacta + detaliu read-only + API raman pe catalogul 3-niveluri (PRD 5.4).
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Rezolvate cu utilizatorul inainte de executie (poarta de aprobare PRD). Toate clarificate prin
|
||||
> AskUserQuestion in sesiunea de planificare:
|
||||
|
||||
- [x] Pill filtre: **un pill per categorie** (Date incomplete / Lipsa cod / Eroare) cu numar, nu un singur pill combinat.
|
||||
- [x] Paginare: **pagini numerotate**, 25 randuri/pagina, total vizibil, pastreaza filtrele.
|
||||
- [x] Editare operatie RAR: **doar pe stari editabile** (`needs_data`/`needs_mapping`/`error`); read-only pe `sent`/`sending`/`queued`.
|
||||
- [x] Layout Mapari: **De rezolvat prima**, apoi salvate / reguli text / formate coloane; fara ajutor, fara empty-text.
|
||||
- [x] Butoane icon: **evidentiere la modificare** (dirty state) pe langa vizibilitatea permanenta.
|
||||
- [x] Branding + paleta (US-012/US-013): rezolvate prin `design-consultation` → `DESIGN.md`.
|
||||
Wordmark „by ROMFAST" sub titlu (ROM rosu `#D1342F` + FAST albastru `#2E74D6`); accent functional
|
||||
**azur ROMFAST** (`#2E74D6`/`#1F66C9`), consistent cu romfast.ro; font IBM Plex Sans + Mono.
|
||||
- [x] Selector de tema (US-014): **buton ciclic** ca demoanaf.ro, set Light/Dark/Petrol/Auto;
|
||||
petrolul (directia initiala) revine ca tema selectabila.
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1 (paralel, fisiere disjuncte):
|
||||
[US-001] fix filtrare data (routes.py)
|
||||
[US-002] operatie service in view (payload_view.py)
|
||||
[US-003] pill-uri filtre (_coada.html / _status.html)
|
||||
[US-005] VIN sub nr (_submissions.html)
|
||||
[US-008] eroare simpla in editare (_trimitere_detaliu.html)
|
||||
[US-009] Mapari in meniu (base.html / dashboard.html)
|
||||
|
||||
Val 2 (deblocate de Val 1):
|
||||
[US-004] paginare ← US-001 (acelasi handler)
|
||||
[US-006] editare op RAR ← (independent, dar atinge _trimitere_detaliu.html → dupa US-008)
|
||||
[US-007] op service detaliu ← US-002
|
||||
[US-010] restructurare Mapari ← US-009
|
||||
|
||||
Val 3:
|
||||
[US-011] butoane icon + dirty state ← US-010
|
||||
|
||||
Branding/tema (dupa design-consultation, ating base.html → serializeaza intre ele):
|
||||
[US-013] paleta azur ROMFAST + IBM Plex ← DESIGN.md (DONE)
|
||||
[US-012] header „by ROMFAST" + titlu centrat ← (independent, dar atinge base.html → dupa US-013)
|
||||
[US-014] selector tema ciclic + Petrol/Auto ← US-013
|
||||
```
|
||||
|
||||
> Nota orchestrare: US-006, US-007, US-008 ating toate `_trimitere_detaliu.html` → serializeaza-le
|
||||
> pe acelasi fisier (nu in worktree-uri paralele). La fel US-010/US-011 pe `_mapari.html`, si
|
||||
> US-012/US-013/US-014 pe `base.html` (toate trei ating header/CSS/script — un singur autor secvential).
|
||||
|
||||
---
|
||||
|
||||
## Raport autoplan (review CEO + Design + Eng)
|
||||
|
||||
> Rulat 2026-06-25. **Codex indisponibil** (usage limit pe contul OpenAI pana 2026-07-18) →
|
||||
> toate fazele degradate la **subagent-only** (`[codex-unavailable]`, single-model). Voci
|
||||
> independente Claude pe fiecare faza (fara context prealabil). Constatarile tehnice critice
|
||||
> verificate direct in cod de lead inainte de aplicare.
|
||||
|
||||
### Consensus (subagent-only — o singura voce per dimensiune)
|
||||
|
||||
```
|
||||
CEO : Premise valide (bug US-001 confirmat real). Recomandare = split branding/tema.
|
||||
DESIGN : Tare pe backend; lacune in stari vizuale + a11y controale noi (emoji, ciclu tema, pill).
|
||||
ENG : 4 must-fix verificate in cod (C1 _is_iso_date prefix; H1 total post-filtru;
|
||||
H3 US-006 nu e reuse pur; M4 card 3n in afara ramurii editabil).
|
||||
```
|
||||
|
||||
### Cross-phase themes (semnalate de 2+ voci independent)
|
||||
- **US-006 e cel mai riscant story** — CEO (riscant, confirma calea) + Eng (H3: NU e reuse pur, cere logica noua + validare nomenclator + assert pe cheia idempotency). Aplicat in AC.
|
||||
- **A11y a controalelor noi** — Design (emoji, ciclu tema, pill fara semantica) e clasa de probleme pe care TDD pe substring Jinja o trece dar livreaza un experience rupt la tastatura/screen-reader. Aplicat (SVG/text, aria-pressed, aria-live).
|
||||
- **Triplu-encoding contoare problema** — Design (pills + „Necesita atentia ta" + badge Mapari). Aplicat: pills inlocuiesc link-urile.
|
||||
|
||||
### Decizii auto (principiile 6) — must-fix aplicate in AC
|
||||
|
||||
| # | Faza | Decizie | Clasificare | Principiu | Rationament |
|
||||
|---|------|---------|-------------|-----------|-------------|
|
||||
| 1 | Eng | US-001: `_iso_date_prefix` (garda+comparatie pe `[:10]`) | Mecanic | P1/P5 | Fara asta bug-ul ramane; o singura solutie corecta |
|
||||
| 2 | Eng | US-004: total ramificat (SQL count fara filtru Python / fetch-all+slice cu filtru) | Mecanic | P1 | Contradictie Fisiere↔AC; SQL COUNT gresit cu filtru Python |
|
||||
| 3 | Eng | US-004: clamp `page`, ascunde pager la `pages<=1`, poll pastreaza pagina | Mecanic | P1 | Edge-cases + regresie poll |
|
||||
| 4 | Eng | US-006: camp `cod_prestatie` + validare nomenclator + assert cheie idempotency (NU reuse pur) | Mecanic | P1/P5 | Verificat in `routes.py:1009-1012` |
|
||||
| 5 | Eng | US-002/007: cheie payload distincta + conventie goala (`—` vs None) | Mecanic | P5 | Chei suprapuse azi; conventie ambigua intre stories |
|
||||
| 6 | Eng | US-008: muta cardul 3n in `{% if not editabil %}` + erori fara camp | Mecanic | P1 | Cardul e azi in afara ramurii editabil |
|
||||
| 7 | Design | US-003: pill-uri butoane reale (aria-pressed), inlocuiesc link-urile, matrice culori categorie (nu accent), sincron cu dropdown | Mecanic | P1 | A11y + anti triplu-encoding |
|
||||
| 8 | Design | US-009: scoate schela `role="tablist"` orfana + JS | Mecanic | P1 | Un tab in tablist = violare ARIA |
|
||||
| 9 | Design | US-011: icon-uri SVG/text (nu emoji brute), dirty-state concret, 44px scopat | Taste→aplicat | P1/P5 | User a scris emoji; SVG satisface intentia + a11y + recolorare (vezi poarta) |
|
||||
| 10 | Design | US-012/013/014: env badge in grila; FOUT pe tabulare; legacy `localStorage.theme`; ciclu a11y; wordmark pe 3 teme | Mecanic | P1 | Stari vizuale lipsa |
|
||||
|
||||
### Decizii de taste / User Challenge — la poarta (NU auto-decise)
|
||||
- **UC-1 (CEO, single-voice)**: split branding/tema (US-012/013/014) intr-un PRD separat + taie Petrol & selectorul ciclic. **Default = PASTRAM** (user a ales explicit in sesiune: Petrol ca tema, ciclu ca demoanaf, IBM Plex). CEO ruleaza ca o singura voce (codex jos) — recomandare, nu decizie.
|
||||
- **UC-2 (CEO)**: paginarea (US-004) poate fi prematura la scara actuala (purge 90z). **Default = PASTRAM** (alegere user); cost low (1 query de validat numarul max randuri/cont inainte de build).
|
||||
- **T-1 (Design A1)**: emoji 💾/🗑 (scrise de user) → SVG/text icon. Aplicat ca SVG (satisface ambele), surfata la poarta fiindca user a scris emoji literal.
|
||||
|
||||
### Deferat (TODOS, nu in 5.10)
|
||||
- Hardening GET-uri de listare globale/neprotejate (CLAUDE.md, semnalat de CEO ca valoare mai mare) — separat, nu expandam acest PRD.
|
||||
- Validare empirica „>200 randuri/cont" pentru US-004 si „cerere reala >2 teme" pentru US-014.
|
||||
|
||||
### Verdict poarta (2026-06-25)
|
||||
- **UC-1 → PASTRAM tot in 5.10** (14 stories, functional + branding/tema). User a confirmat la poarta; CEO single-voice = recomandare, nu verdict. Riscul FOUC/FOUT mitigat prin AC-urile adaugate.
|
||||
- **UC-2 → PASTRAM paginarea** (US-004). Cost mic, specificata corect dupa review.
|
||||
- **T-1 → icon-uri SVG/text** (nu emoji brute), aplicat in US-011.
|
||||
- **APROBAT.** Toate must-fix-urile aplicate in AC. Gata de executie (TDD prin echipa, vezi valuri §6) sau `/ralph`.
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat, fara transcriptul executiei) — ROADMAP §5.6. 2026-06-25.
|
||||
|
||||
**Verdict: PASS** — toate cele 14 stories PASS, 0 FAIL. Regresie completa **892 passed / 1 skipped / 0 failed** (+49 teste fata de baseline 843; skipped = test `live` RAR, opt-in). Backend trimitere (worker/masina stari/idempotenta/mapping) + schema-send NEATINSE (Non-Goals respectate).
|
||||
|
||||
PASS per story cu dovezi verificate direct in cod:
|
||||
- US-001 PASS — `_iso_date_prefix` folosit ATAT pentru garda CAT si comparatie (`routes.py`), interval inclusiv, ne-ISO excluse.
|
||||
- US-002 PASS — chei distincte `op_service_cod`/`op_service_denumire`, conventie goala `""` (nu `—`).
|
||||
- US-003 PASS — pill-uri `<button>` cu `aria-pressed`; needs_mapping `--warn`, needs_data/error `--err`; dropdown status eliminat (fara desync); lista ID-uri eliminata.
|
||||
- US-004 PASS — total ramificat (SQL COUNT fara filtru Python / fetch-all+slice cu filtru); clamp page; poll pastreaza pagina (OOB `f-page`); pager ascuns la `pages<=1`.
|
||||
- US-005 PASS — VIN element block-level (`<div>`) sub nr, garda VIN lipsa.
|
||||
- US-006 PASS — logica noua in `post_corectie_trimitere` (citeste `cod_prestatie`, valideaza nomenclator, injecteaza in `content["prestatii"][0]` inainte de `resolve_prestatii`); cheia idempotency difera; read-only pe sent/sending/queued.
|
||||
- US-007 PASS — Operatie service distinct de Operatie RAR, absent cand lipseste, in editabil + read-only.
|
||||
- US-008 PASS — card 3n in `{% if not editabil %}`; erori fara camp = rezumat top-of-form in editare.
|
||||
- US-009 PASS — Mapari in `#cont-menu` cu badge; tab-bar + `role=tablist` + JS sageti eliminate; `?tab=mapari` nu da 404.
|
||||
- US-010 PASS — ordine sectiuni corecta; `ajutor-mapari` + empty-text eliminate.
|
||||
- US-011 PASS — butoane `.icon-btn` SVG (fara emoji); kebab eliminat; dirty-state pe `change` select; `hx-confirm` pastrat.
|
||||
- US-012 PASS — header grid 3col centrat; env badge in grila; wordmark `by ROMFAST` (ROM `#D1342F`, FAST `#2E74D6`) text, nu PNG.
|
||||
- US-013 PASS — accent `#2E74D6`/`#1F66C9`/`#0E7C7B`; `@font-face` IBM Plex Sans/Mono. **Rezerva fonturi woff2 placeholder REZOLVATA post-VERIFY**: cele 8 fisiere au fost inlocuite cu subseturile reale IBM Plex (fontsource `@fontsource/ibm-plex-*@5.0.8`, woff2 valide latin + latin-ext, 400/500/700 sans + 400 mono).
|
||||
- US-014 PASS — `[data-theme=petrol]` `#0E7C7B`; ciclu Light->Dark->Petrol->Auto; anti-FOUC extins la 4 stari + fallback legacy; `aria-label` curenta+urmatoarea + `aria-live`.
|
||||
|
||||
**Limitari documentate:**
|
||||
- E2E browser NEPROBAT in sandbox (fara browser interactiv, consistent cu 5.8/5.9). Recomandat la deploy: `./start.sh test both --send` + browser pe `http://localhost:8000/` pentru proba vizuala (pill-uri/click, paginare >25, VIN sub nr, ciclu tema fara blink, header centrat, Mapari din ☰).
|
||||
- Live RAR `--send` neprobat (UI pur; backend trimitere neatins — risc minim).
|
||||
|
||||
### CLOSE — `/code-review high` (2026-06-25)
|
||||
|
||||
1 finding material reparat TDD (US-006b): US-006 acoperea doar `needs_data`/`needs_mapping`, dar AC + intrebarea deschisa cer si `error`. Fix: constanta separata `_EDITABILE_OP=(needs_data,needs_mapping,error)` pentru selectul de cod RAR; pe `error` editarea codului trece prin `/repune` (re-queue), cu validare nomenclator, re-rezolvare, canonicalize + `build_key` (cheie idempotency noua), check coliziune (pre-UPDATE + `IntegrityError`), `error→queued` (rar_error NULL / retry_count 0). `_CORECTABILE` + `post_corectie_trimitere` neatinse (fara regresie US-010/US-011). 9/9 teste editare; regresie completa **896 passed / 1 skipped / 0 failed**.
|
||||
|
||||
Findings minore (debt acceptat, non-blocante): (a) comparatie data bruta `d_prefix > data_pana` — sigura prin UI (`<input type=date>` zero-padded), vulnerabila doar la URL fabricat; (b) dublu `load_nomenclator` cand nomenclatorul e legitim gol; (c) linkurile de paginare reafiseaza `vehicul` cu majuscule (cosmetic); (d) duplicare cale validare+inject+idempotency intre `post_corectie_trimitere` si `post_repune_trimitere` (candidat de extras intr-un helper).
|
||||
|
||||
**US-012b (decizie user post-review):** in header se foloseste LOGO-ul PNG real (`/static/romfast_logo.png`, `.brand-logo` ~28px) in loc de wordmark-ul text din US-012. Fundal transparent + culori proprii -> lizibil pe toate temele. `test_web_header_branding.py` actualizat sa verifice `<img romfast_logo.png>` + `alt`. DESIGN.md actualizat. Regresie ramane 896 passed.
|
||||
|
||||
**Stare finala: VERIFY PASS + fix code-review (US-006b) + logo header (US-012b) aplicate. Gata de commit (poarta umana).**
|
||||
BIN
docs/romfast_logo.png
Normal file
BIN
docs/romfast_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -50,14 +50,16 @@ def _seed_submission(status: str = "sent", n: int = 1) -> None:
|
||||
|
||||
|
||||
def test_tab_bar_fara_trimiteri(client):
|
||||
"""Tab-bar-ul nu mai contine tab-ul 'Trimiteri' (coada); raman 4 tab-uri."""
|
||||
"""US-009: tab-bar eliminat; 'Coada' nu exista; Mapari/Cont/Nomenclator raman in meniu."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
# "Coada" nu trebuie sa existe nici ca tab, nici ca link in meniu
|
||||
assert 'id="tab-coada"' not in html
|
||||
assert 'href="/?tab=coada"' not in html
|
||||
for label in ("Acasa", "Mapari", "Cont", "Nomenclator"):
|
||||
assert f">{label}" in html or f"{label}<" in html, f"lipseste tab {label}"
|
||||
# US-009: tab-bar eliminat; Mapari/Cont/Nomenclator sunt in meniul hamburger
|
||||
for label in ("Mapari", "Cont", "Nomenclator"):
|
||||
assert f">{label}" in html or f"{label}<" in html, f"lipseste intrarea {label} in meniu"
|
||||
|
||||
|
||||
def test_acasa_contine_sectiunea_trimiteri(client):
|
||||
@@ -126,10 +128,19 @@ def test_badge_trimiteri_scoped_pe_acasa(client):
|
||||
assert "2" in html[idx:idx + 400]
|
||||
|
||||
|
||||
def test_trimiteri_poll_aliniat_15s(client):
|
||||
"""Poll-ul de trimiteri e aliniat la 15s (anti dublu-poll M5), nu 10s."""
|
||||
def test_trimiteri_fara_poll_periodic_pe_tabel(client):
|
||||
"""Tabelul de trimiteri NU se mai reimprospateaza periodic: #submissions-wrap se
|
||||
incarca la load / actiunile utilizatorului / Reincarca (nudge), fara `every Ns`.
|
||||
Reimprospatarea live se face prin nudge-ul "Date noi" + endpointul de versiune."""
|
||||
_seed_submission("sent")
|
||||
r = client.get("/?tab=acasa")
|
||||
html = r.text
|
||||
assert "every 15s" in html
|
||||
assert "every 10s" not in html
|
||||
# Trigger-ul tabelului nu contine poll periodic.
|
||||
wrap = html[html.find('id="submissions-wrap"'):]
|
||||
wrap = wrap[:wrap.find(">") + 1]
|
||||
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
|
||||
assert "reincarcaTrimiteri" in wrap
|
||||
assert "trimiteriChanged" in wrap
|
||||
# Mecanismul de nudge exista (banner + endpoint versiune).
|
||||
assert 'id="nudge-trimiteri"' in html
|
||||
assert "/_fragments/trimiteri-versiune" in html
|
||||
|
||||
@@ -146,3 +146,69 @@ def test_operatie_ramane_denumire_sau_op():
|
||||
})
|
||||
assert d3["operatie"] == "Verificare"
|
||||
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)
|
||||
|
||||
|
||||
# --- US-002: op_service_cod + op_service_denumire distincte ---
|
||||
|
||||
def test_operatie_service_din_cod_op_service():
|
||||
"""cod_op_service prezent -> op_service_cod contine valoarea; string gol cand lipseste."""
|
||||
# cod_op_service prezent -> op_service_cod populated
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
|
||||
})
|
||||
assert d["op_service_cod"] == "OP-77"
|
||||
|
||||
# cod_op_service absent (vine direct cu cod_prestatie) -> op_service_cod == "" (NU EMPTY="—")
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d2["op_service_cod"] == ""
|
||||
assert d2["op_service_cod"] != "—"
|
||||
|
||||
|
||||
def test_operatie_service_din_denumire():
|
||||
"""denumire prezenta cu cod_op_service -> op_service_denumire contine valoarea."""
|
||||
# ambele prezente -> op_service_denumire = denumire
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
|
||||
})
|
||||
assert d["op_service_denumire"] == "Verificare faruri"
|
||||
|
||||
# cod_op_service prezent dar fara denumire -> op_service_denumire == ""
|
||||
d2 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_op_service": "OP-77"}]
|
||||
})
|
||||
assert d2["op_service_denumire"] == ""
|
||||
|
||||
# cod_op_service absent + denumire prezenta -> op_service_denumire == "" (nu expune denumire RAR)
|
||||
d3 = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}]
|
||||
})
|
||||
assert d3["op_service_denumire"] == ""
|
||||
|
||||
|
||||
def test_fara_operatie_service_cand_lipseste():
|
||||
"""Payload fara cod_op_service -> op_service_cod si op_service_denumire sunt "" (nu "—"), fara exceptie."""
|
||||
# vine direct cu cod_prestatie
|
||||
d = prezentare_din_payload({
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE"}]
|
||||
})
|
||||
assert d["op_service_cod"] == ""
|
||||
assert d["op_service_denumire"] == ""
|
||||
assert d["op_service_cod"] != "—"
|
||||
assert d["op_service_denumire"] != "—"
|
||||
|
||||
# fara prestatii deloc
|
||||
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
|
||||
assert d2["op_service_cod"] == ""
|
||||
assert d2["op_service_denumire"] == ""
|
||||
|
||||
# payload None/gol
|
||||
d3 = prezentare_din_payload(None)
|
||||
assert d3["op_service_cod"] == ""
|
||||
assert d3["op_service_denumire"] == ""
|
||||
|
||||
# payload JSON invalid
|
||||
d4 = prezentare_din_payload("nu-e-json")
|
||||
assert d4["op_service_cod"] == ""
|
||||
assert d4["op_service_denumire"] == ""
|
||||
|
||||
@@ -71,13 +71,15 @@ def test_paleta_light_definita(client):
|
||||
|
||||
|
||||
def test_dark_ramane_default(client):
|
||||
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
|
||||
""":root contine paleta dark exacta: --bg:#0f1218, --card:#181c24, --ink:#e6e9ef.
|
||||
Valorile actualizate la US-013 (PRD 5.10) conform DESIGN.md (accent azur ROMFAST).
|
||||
"""
|
||||
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 "--bg:#0f1218" in html, "Paleta dark --bg:#0f1218 a fost modificata sau stearsa"
|
||||
assert "--card:#181c24" in html, "Paleta dark --card:#181c24 a fost modificata sau stearsa"
|
||||
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
def test_badge_mapari(client):
|
||||
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
|
||||
"""US-009: cu operatii needs_mapping, intrarea Mapari din meniu poarta un badge cu numar."""
|
||||
acct = _create_account_user("bm@test.com")
|
||||
_ins(acct, "needs_mapping")
|
||||
_ins(acct, "needs_mapping")
|
||||
@@ -82,10 +82,14 @@ def test_badge_mapari(client):
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
link = _tab_link(resp.text, "tab-mapari")
|
||||
assert "tab-badge" in link
|
||||
assert "2" in link
|
||||
assert "necesita atentie" in link # aria-label
|
||||
html = resp.text
|
||||
# US-009: Mapari e acum in meniu (nu tab); badgeul apare in intrarea meniului
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1, "Intrarea Mapari lipseste din meniu"
|
||||
# Cauta badgeul in contextul link-ului Mapari
|
||||
window = html[idx:idx + 300]
|
||||
assert "tab-badge" in window, "Badgeul (tab-badge) trebuie sa apara langa intrarea Mapari"
|
||||
assert "2" in window, "Contorul 2 trebuie sa apara in badge-ul Mapari"
|
||||
|
||||
|
||||
def test_badge_trimiteri_blocate(client):
|
||||
|
||||
@@ -85,9 +85,7 @@ def test_tab_import_redirect(client):
|
||||
html = resp.text
|
||||
# Echivalent Acasa: contine upload-ul (import-section)
|
||||
assert 'id="import-section"' in html
|
||||
# Acasa e tab-ul activ (import nu mai e tab valid separat)
|
||||
assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \
|
||||
"?tab=import ar trebui sa cada pe Acasa activ"
|
||||
# US-009: tab-bar eliminat, nu mai exista tab-uri cu aria-selected
|
||||
|
||||
|
||||
def test_tab_bar_fara_import(client):
|
||||
|
||||
207
tests/test_web_detaliu_eroare_simpla.py
Normal file
207
tests/test_web_detaliu_eroare_simpla.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Teste US-008 (PRD 5.10): simplificare erori in formularul de editare.
|
||||
|
||||
Problema actuala: cardul erori_3n/card_erori (clasa eroare-3n) e randat INAINTE de form,
|
||||
in afara ramurii `{% if editabil %}` — deci apare si in contextul de editare.
|
||||
|
||||
US-008 cere:
|
||||
- In editare: cardul 3-niveluri (`eroare-3n`) DISPARUT; erori per-camp raman ca text simplu
|
||||
subliniat (.s-error); erori fara camp (field None) apar ca rezumat simplu top-of-form.
|
||||
- In read-only: cardul 3-niveluri se pastreaza (comportament existent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
resp = client.get("/")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _insert(acct: int, *, status: str, rar_error: str | None = None,
|
||||
vin: str = "WVWZZZ1JZXW000099", nr: str = "B100TST") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
})
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions "
|
||||
"(idempotency_key, account_id, status, payload_json, rar_error) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"k-{os.urandom(6).hex()}", acct, status, payload, rar_error),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eroare_simpla.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 test_form_editare_fara_card_3niveluri(client):
|
||||
"""In contextul editabil (needs_data), cardul erori_3n (eroare-3n) NU apare.
|
||||
|
||||
Problema curenta: cardul e randat INAINTE de form, in afara ramurii editabil,
|
||||
deci apare atat in editare cat si read-only. US-008 il muta in `{% if not editabil %}`.
|
||||
"""
|
||||
acct = _create_account_user("edit3n@test.com")
|
||||
# needs_data cu rar_error care contine o eroare cu field — format {field, message}
|
||||
rar_error = json.dumps([
|
||||
{"field": "odometruFinal", "message": "Odometru trebuie sa fie un numar intreg (ca string)."}
|
||||
])
|
||||
sid = _insert(acct, status="needs_data", rar_error=rar_error)
|
||||
_login(client, "edit3n@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# In editare: cardul cu 3 niveluri NU trebuie sa apara
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul erori_3n (eroare-3n) NU trebuie sa apara in contextul editabil (needs_data). "
|
||||
"US-008: muta-l in '{% if not editabil %}'."
|
||||
)
|
||||
assert 'eroare-3n-item' not in html, (
|
||||
"Itemii card-ului 3n (eroare-3n-item) nu trebuie sa apara in editare."
|
||||
)
|
||||
# Formularul de editare trebuie sa ramana prezent
|
||||
assert 'hx-post=' in html and 'corecteaza' in html, "Formularul de corectie trebuie sa existe"
|
||||
|
||||
|
||||
def test_eroare_pe_camp_doar_text_simplu(client):
|
||||
"""Dupa o corectie invalida, eroarea per-camp apare ca .s-error text simplu, nu card 3n.
|
||||
|
||||
Macro-ul `camp` deja printeaza doar mesajul simplu — testul verifica ca
|
||||
`eroare-3n` nu exista in raspuns (nu e dobla-randat odata prin card si odata prin macro).
|
||||
"""
|
||||
acct = _create_account_user("simplu@test.com")
|
||||
sid = _insert(acct, status="needs_data", vin="WVWZZZ1JZXW000001", nr="B100TST")
|
||||
_login(client, "simplu@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# POST cu odometru invalid (non-numeric) — ramane needs_data + eroare per-camp
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={
|
||||
"nr_inmatriculare": "B100TST",
|
||||
"vin": "WVWZZZ1JZXW000001",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "nu-e-numar",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Eroarea per-camp trebuie sa apara ca text simplu (.s-error)
|
||||
assert 's-error' in html, "Eroarea per-camp trebuie sa apara ca .s-error text simplu"
|
||||
|
||||
# Cardul 3-niveluri NU trebuie sa apara in contextul editabil
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul eroare-3n NU trebuie sa apara in contextul editabil. "
|
||||
"US-008: randat doar in read-only ({% if not editabil %})."
|
||||
)
|
||||
|
||||
|
||||
def test_eroare_fara_camp_apare_ca_rezumat_in_editare(client):
|
||||
"""Erori cu field=None nu dispar silentios in editare — apar ca rezumat simplu top-of-form.
|
||||
|
||||
Bug M6: template-ul filtra erorile in `err_map` DOAR pe cele cu field,
|
||||
iar cardul 3n (ascuns in editare) era singurul canal de afisare pentru field=None.
|
||||
US-008: adauga un rezumat simplu (div .s-error sau similar) in ramura editabil.
|
||||
"""
|
||||
acct = _create_account_user("faracam@test.com")
|
||||
# rar_error cu o eroare FARA camp (field=None) — ex. eroare globala de la RAR
|
||||
rar_error = json.dumps([
|
||||
{"problema": "Date incomplete la nivel de prezentare", "cauza": "", "fix": "", "field": None}
|
||||
])
|
||||
sid = _insert(acct, status="needs_data", rar_error=rar_error)
|
||||
_login(client, "faracam@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Mesajul erorii globale trebuie sa fie prezent (nu silentios disparut)
|
||||
assert "Date incomplete la nivel de prezentare" in html, (
|
||||
"Eroarea fara camp (field=None) trebuie sa apara in contextul editabil. "
|
||||
"US-008 (M6): adauga rezumat simplu top-of-form in ramura '{% if editabil %}'."
|
||||
)
|
||||
# Dar NU ca card 3-niveluri
|
||||
assert 'class="eroare-3n"' not in html, (
|
||||
"Cardul eroare-3n NU trebuie sa apara in contextul editabil."
|
||||
)
|
||||
|
||||
|
||||
def test_readonly_pastreaza_card_3niveluri(client):
|
||||
"""In contextul read-only (error/sent), cardul erori_3n se pastreaza neschimbat."""
|
||||
acct = _create_account_user("readonly3n@test.com")
|
||||
rar_error = json.dumps([
|
||||
{"problema": "Eroare RAR server", "cauza": "ORA-12899", "fix": "Reverifica datele", "field": None}
|
||||
])
|
||||
sid = _insert(acct, status="error", rar_error=rar_error)
|
||||
_login(client, "readonly3n@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# In read-only: cardul eroare-3n TREBUIE sa apara
|
||||
assert 'class="eroare-3n"' in html, (
|
||||
"Cardul erori_3n (eroare-3n) trebuie pastrat in contextul read-only (error)."
|
||||
)
|
||||
191
tests/test_web_detaliu_op_service.py
Normal file
191
tests/test_web_detaliu_op_service.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Teste US-007 (PRD 5.10): afisare operatie de service in detaliu.
|
||||
|
||||
Cand payload-ul contine `cod_op_service` (codul intern al service-ului), detaliul
|
||||
trebuie sa afiseze „Operatie service" (cod + denumire), distinct de „Operatie RAR".
|
||||
Cand lipseste (a venit direct cu `cod_prestatie`), randul nu apare deloc — conventie
|
||||
"" (string gol) stabilita de US-002 in payload_view.py.
|
||||
|
||||
Apare atat in contextul editabil (needs_data/needs_mapping) cat si read-only (sent/error).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _insert(acct: int, *, status: str, payload: dict) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _payload_cu_op_service(vin: str = "WVWZZZ1JZXW000001") -> dict:
|
||||
"""Payload cu cod_op_service + denumire (vine prin API cu cod intern)."""
|
||||
return {
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": "B100TST",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [
|
||||
{
|
||||
"cod_op_service": "OP-FRANE-77",
|
||||
"denumire": "Verificare si reglaj frane",
|
||||
"cod_prestatie": "OE-1", # mapat deja
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _payload_fara_op_service(vin: str = "WVWZZZ1JZXW000002") -> dict:
|
||||
"""Payload cu cod_prestatie direct (fara cod_op_service)."""
|
||||
return {
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": "B200TST",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "50000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "op_service_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 test_detaliu_arata_operatie_service_read_only(client):
|
||||
"""In context read-only (sent), operatia de service (cod intern + denumire) apare distinct.
|
||||
|
||||
Randul „Operatie service" trebuie sa fie vizibil si sa contina codul intern
|
||||
si denumirea venita prin API, separat de „Operatie RAR".
|
||||
"""
|
||||
acct = _create_account_user("op_srv_ro@test.com")
|
||||
sid = _insert(acct, status="sent", payload=_payload_cu_op_service())
|
||||
_login(client, "op_srv_ro@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Codul intern de service trebuie sa apara
|
||||
assert "OP-FRANE-77" in html, (
|
||||
"Codul intern al operatiei de service (op_service_cod) trebuie afisat in detaliu read-only."
|
||||
)
|
||||
# Denumirea trebuie sa apara
|
||||
assert "Verificare si reglaj frane" in html, (
|
||||
"Denumirea operatiei de service (op_service_denumire) trebuie afisata in detaliu read-only."
|
||||
)
|
||||
# Eticheta „Operatie service" trebuie sa apara
|
||||
assert "Operatie service" in html, (
|
||||
"Eticheta 'Operatie service' trebuie sa apara in detaliu read-only."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_arata_operatie_service_editabil(client):
|
||||
"""In context editabil (needs_data), operatia de service apare de asemenea.
|
||||
|
||||
Campul este read-only in forma (nu e editabil de operator), dar trebuie afisat
|
||||
ca referinta pentru ce a cerut service-ul.
|
||||
"""
|
||||
acct = _create_account_user("op_srv_edit@test.com")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload_cu_op_service())
|
||||
_login(client, "op_srv_edit@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Codul intern trebuie sa apara si in editare
|
||||
assert "OP-FRANE-77" in html, (
|
||||
"Codul intern al operatiei de service trebuie afisat si in contextul editabil (needs_data)."
|
||||
)
|
||||
# Eticheta trebuie sa apara
|
||||
assert "Operatie service" in html, (
|
||||
"Eticheta 'Operatie service' trebuie sa apara si in contextul editabil."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_omite_cand_lipseste_read_only(client):
|
||||
"""Cand payload-ul nu are cod_op_service (vine direct cu cod_prestatie), randul nu apare.
|
||||
|
||||
Conventie US-002: op_service_cod = "" (nu "—"). Randul lipseste complet
|
||||
(nu apare „Operatie service: —" sau rand gol).
|
||||
"""
|
||||
acct = _create_account_user("op_srv_absent_ro@test.com")
|
||||
sid = _insert(acct, status="sent", payload=_payload_fara_op_service())
|
||||
_login(client, "op_srv_absent_ro@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Eticheta „Operatie service" nu trebuie sa apara cand lipseste
|
||||
assert "Operatie service" not in html, (
|
||||
"Randul 'Operatie service' nu trebuie sa apara cand payload-ul nu contine cod_op_service. "
|
||||
"Conventie US-002: op_service_cod='' → rand absent complet."
|
||||
)
|
||||
|
||||
|
||||
def test_detaliu_omite_cand_lipseste_editabil(client):
|
||||
"""Cand payload-ul nu are cod_op_service, randul nu apare nici in editare."""
|
||||
acct = _create_account_user("op_srv_absent_edit@test.com")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload_fara_op_service())
|
||||
_login(client, "op_srv_absent_edit@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert "Operatie service" not in html, (
|
||||
"Randul 'Operatie service' nu trebuie sa apara in editare cand lipseste cod_op_service."
|
||||
)
|
||||
359
tests/test_web_editare_op_rar.py
Normal file
359
tests/test_web_editare_op_rar.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Teste US-006 (PRD 5.10): editare operatie RAR (cod_prestatie) din formularul de detaliu.
|
||||
|
||||
Stari editabile: needs_data, needs_mapping (stari cu formular de corectie activ).
|
||||
Read-only: sent/sending/queued/error (fara select cod_prestatie).
|
||||
|
||||
Cazuri:
|
||||
- test_editabil_arata_select_cod_rar: detaliu needs_data → HTML are <select name="cod_prestatie">
|
||||
- test_salvare_schimba_cod_si_repune_in_coada: POST cu cod_prestatie=OE-2 → payload actualizat + status queued
|
||||
- test_idempotency_key_se_schimba: schimbarea codului → cheie idempotency noua
|
||||
- test_cod_invalid_respins: cod necunoscut in nomenclator → respins (status neschimbat)
|
||||
- test_sent_nu_arata_select: detaliu sent → fara <select name="cod_prestatie">
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# VIN valid: 17 caractere, fara I/O/Q
|
||||
VIN_US006 = "WVWZZZ1JZXW0E6001"
|
||||
|
||||
# Payload complet valid (trece validate_prezentare)
|
||||
PAYLOAD_VALID = {
|
||||
"vin": VIN_US006,
|
||||
"nr_inmatriculare": "B100AAA",
|
||||
"data_prestatie": "2026-06-10",
|
||||
"odometru_final": "55000",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str, payload: dict | None = None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(f"k-us006-{os.urandom(6).hex()}", acct, status, json.dumps(payload or PAYLOAD_VALID)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _ins_nomenclator(*codes: str) -> None:
|
||||
"""Insereaza coduri RAR in nomenclator_rar (tabelul e gol in DB-ul de test)."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for cod in codes:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
(cod, f"Operatie test {cod}"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(sid: int):
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _csrf(client) -> str:
|
||||
"""CSRF token din pagina principala (sesiune activa necesara)."""
|
||||
resp = client.get("/")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m, f"CSRF token negasit in pagina principala: {resp.text[:500]}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _detaliu(client, sid: int) -> str:
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
return resp.text
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rar.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 test_editabil_arata_select_cod_rar(client):
|
||||
"""needs_data cu nomenclator populat → formularul de detaliu afiseaza <select name='cod_prestatie'>."""
|
||||
acct = _create_account_user("sel1@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "sel1@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' in html, (
|
||||
"Formularul de detaliu needs_data trebuie sa contina un select cu name='cod_prestatie'"
|
||||
)
|
||||
assert "<select" in html, (
|
||||
"Elementul <select> trebuie sa apara in detaliu pentru starea needs_data"
|
||||
)
|
||||
# Optiunile din select contin codurile din nomenclator
|
||||
assert "OE-1" in html, "Codul OE-1 trebuie sa apara in optiunile select-ului"
|
||||
assert "OE-2" in html, "Codul OE-2 trebuie sa apara in optiunile select-ului"
|
||||
|
||||
|
||||
def test_salvare_schimba_cod_si_repune_in_coada(client):
|
||||
"""POST /corecteaza cu cod_prestatie=OE-2 → payload actualizat + status=queued."""
|
||||
acct = _create_account_user("sav2@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "sav2@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "queued", (
|
||||
f"Dupa salvarea cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
|
||||
)
|
||||
payload = json.loads(row["payload_json"])
|
||||
prestatii = payload.get("prestatii") or []
|
||||
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie dupa corectie"
|
||||
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
|
||||
assert cod_nou == "OE-2", (
|
||||
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
|
||||
)
|
||||
|
||||
|
||||
def test_idempotency_key_se_schimba(client):
|
||||
"""Schimbarea cod_prestatie (OE-1 → OE-2) recalculeaza cheia de idempotency.
|
||||
|
||||
Verificare stricta: cheia calculata dupa POST cu OE-2 difera de cheia CANONICALA
|
||||
cu OE-1. Daca cod_prestatie nu e injectat inainte de build_key, cheia ramane cea
|
||||
cu OE-1 si testul pica. Este RED inainte de implementarea US-006.
|
||||
"""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
|
||||
acct = _create_account_user("idem3@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "idem3@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# Cheia canonicala cu OE-1 = ce ar produce un POST fara cod_prestatie (sau cu OE-1)
|
||||
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
|
||||
key_oe1 = build_key(acct, canon_oe1)
|
||||
|
||||
# POST cu cod_prestatie=OE-2: implementarea US-006 trebuie sa injecteze OE-2 in payload
|
||||
client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
|
||||
cheie_noua = _row(sid)["idempotency_key"]
|
||||
assert cheie_noua != key_oe1, (
|
||||
"Cheia calculata dupa POST cu OE-2 trebuie sa difere de cheia cu OE-1. "
|
||||
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key (US-006 H3)."
|
||||
)
|
||||
|
||||
|
||||
def test_cod_invalid_respins(client):
|
||||
"""Cod RAR necunoscut in nomenclator → randul ramane needs_data (nu se re-cueaza)."""
|
||||
acct = _create_account_user("inv4@test.com")
|
||||
_ins_nomenclator("OE-1") # "ZZ-INVALID" nu exista
|
||||
sid = _ins(acct, status="needs_data")
|
||||
_login(client, "inv4@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/corecteaza",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "ZZ-INVALID"},
|
||||
)
|
||||
# Raspunsul e 200 (eroare in pagina, nu redirect)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "needs_data", (
|
||||
f"Un cod invalid trebuie sa lase randul in needs_data, nu '{row['status']}'"
|
||||
)
|
||||
# Mesajul de eroare trebuie sa apara in raspuns
|
||||
assert "ZZ-INVALID" in resp.text or "necunoscut" in resp.text.lower(), (
|
||||
"Raspunsul trebuie sa indice ca codul este necunoscut in nomenclator"
|
||||
)
|
||||
|
||||
|
||||
def test_sent_nu_arata_select(client):
|
||||
"""Trimitere cu status=sent → fara <select name='cod_prestatie'> in detaliu (read-only)."""
|
||||
acct = _create_account_user("ro5@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="sent")
|
||||
_login(client, "ro5@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' not in html, (
|
||||
"Starea sent trebuie sa fie read-only (fara select cod_prestatie)"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# US-006b: extindere la starea error
|
||||
# ================================================================
|
||||
|
||||
def test_error_arata_select_cod_rar(client):
|
||||
"""needs_data/needs_mapping primeau select (US-006); error trebuie sa primeasca si el
|
||||
un select cod_prestatie in formularul 'Re-pune in coada' (US-006b)."""
|
||||
acct = _create_account_user("err1@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="error")
|
||||
_login(client, "err1@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' in html, (
|
||||
"Starea error trebuie sa afiseze un select cod_prestatie (US-006b)"
|
||||
)
|
||||
assert "<select" in html, "Elementul <select> trebuie sa apara in detaliu pentru error"
|
||||
# Codurile din nomenclator trebuie sa fie in optiuni
|
||||
assert "OE-1" in html and "OE-2" in html, (
|
||||
"Codurile din nomenclator trebuie sa apara in select-ul pentru error"
|
||||
)
|
||||
# NU trebuie sa afiseze formularul complet de corectie (fara /corecteaza)
|
||||
assert f"/trimitere/{sid}/corecteaza" not in html, (
|
||||
"Starea error NU trebuie sa aiba formular /corecteaza (US-006b foloseste /repune)"
|
||||
)
|
||||
# Butonul principal ramane 'Re-pune in coada' (nu 'Salveaza si retrimite')
|
||||
assert "Re-pune in coada" in html
|
||||
assert "Salveaza si retrimite" not in html
|
||||
|
||||
|
||||
def test_error_salvare_schimba_cod_si_repune_in_coada(client):
|
||||
"""POST /repune cu cod_prestatie=OE-2 pe un rand error → payload actualizat + status=queued."""
|
||||
acct = _create_account_user("err2@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="error")
|
||||
_login(client, "err2@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/trimitere/{sid}/repune",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = _row(sid)
|
||||
assert row["status"] == "queued", (
|
||||
f"Dupa repune cu OE-2, randul trebuie sa fie queued, nu '{row['status']}'"
|
||||
)
|
||||
payload = json.loads(row["payload_json"])
|
||||
prestatii = payload.get("prestatii") or []
|
||||
assert prestatii, "Payload-ul trebuie sa contina cel putin o prestatie"
|
||||
cod_nou = (prestatii[0].get("cod_prestatie") or "").strip().upper()
|
||||
assert cod_nou == "OE-2", (
|
||||
f"cod_prestatie in payload trebuie sa fie OE-2, nu '{cod_nou}'"
|
||||
)
|
||||
|
||||
|
||||
def test_error_idempotency_key_se_schimba(client):
|
||||
"""Schimbarea cod_prestatie (OE-1 → OE-2) la repune recalculeaza cheia de idempotency.
|
||||
|
||||
Randul e inserat CU CHEIA CANONICA pentru OE-1 (nu random), ca sa fie RED inainte
|
||||
de implementare: fara injectare, repune nu schimba cheia (ramane OE-1) → test FAIL.
|
||||
Dupa implementare, POST cu OE-2 → cheie noua (canonicala cu OE-2) ≠ cheie OE-1.
|
||||
"""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
from app.db import get_connection as _gc
|
||||
|
||||
acct = _create_account_user("err3@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
|
||||
# Calculeaza cheia canonicala pentru OE-1 si insereaza randul CU acea cheie.
|
||||
canon_oe1 = canonicalize_row({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})
|
||||
key_oe1 = build_key(acct, canon_oe1)
|
||||
|
||||
# Inserare cu cheia cunoscuta (nu random), ca sa avem un baseline deterministic.
|
||||
conn = _gc()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(key_oe1, acct, "error", json.dumps({**PAYLOAD_VALID, "prestatii": [{"cod_prestatie": "OE-1"}]})),
|
||||
)
|
||||
conn.commit()
|
||||
sid = int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_login(client, "err3@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# POST /repune cu OE-2 → implementarea trebuie sa recalculeze cheia
|
||||
client.post(
|
||||
f"/trimitere/{sid}/repune",
|
||||
data={"csrf_token": csrf, "cod_prestatie": "OE-2"},
|
||||
)
|
||||
|
||||
cheie_noua = _row(sid)["idempotency_key"]
|
||||
assert cheie_noua != key_oe1, (
|
||||
"Cheia idempotency trebuie sa difere dupa schimbarea cod_prestatie la repune (US-006b). "
|
||||
"Daca sunt egale, cod_prestatie nu a fost injectat inainte de build_key."
|
||||
)
|
||||
|
||||
|
||||
def test_queued_nu_arata_select(client):
|
||||
"""Trimitere queued → fara select cod_prestatie (read-only; doar error/needs_* primesc select)."""
|
||||
acct = _create_account_user("ro6@test.com")
|
||||
_ins_nomenclator("OE-1", "OE-2")
|
||||
sid = _ins(acct, status="queued")
|
||||
_login(client, "ro6@test.com")
|
||||
|
||||
html = _detaliu(client, sid)
|
||||
|
||||
assert 'name="cod_prestatie"' not in html, (
|
||||
"Starea queued trebuie sa fie read-only (fara select cod_prestatie)"
|
||||
)
|
||||
148
tests/test_web_filtre_submissions.py
Normal file
148
tests/test_web_filtre_submissions.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Teste US-001 (PRD 5.10): fix filtrare pe interval de data in fragment_submissions.
|
||||
|
||||
Cazuri:
|
||||
- timestamp-uri cu ora (ex. "2026-06-20 14:35:07") trebuie incluse la filtrare pe ziua respectiva
|
||||
- interval inclusiv la ambele capete
|
||||
- valori ne-ISO raman excluse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str = "queued", vin: str, nr: str, data: str) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": data,
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(sid: int) -> str:
|
||||
"""Selector HTML pentru randul cu ID-ul dat."""
|
||||
return f'id="trimitere-row-{sid}"'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre_data.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 test_filtru_data_include_timestamp_cu_ora(client):
|
||||
"""Un rand cu data_prestatie = '2026-06-20 14:35:07' trebuie sa apara
|
||||
cand filtrul e data_de=2026-06-20 si data_pana=2026-06-20.
|
||||
Bug actual: _is_iso_date verifica len==10 → exclude timestamp → randul dispare."""
|
||||
acct = _create_account_user("ts_ora@test.com")
|
||||
# timestamp cu ora — asta e cauza bug-ului
|
||||
sid_ora = _ins(acct, vin="WVIN001TS001ORA0001", nr="B01TS", data="2026-06-20 14:35:07")
|
||||
# rand fara ora, in afara intervalului (nu trebuie sa apara)
|
||||
sid_alt = _ins(acct, vin="WVIN001TS001ALT0002", nr="B02TS", data="2026-06-21")
|
||||
_login(client, "ts_ora@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
|
||||
assert resp.status_code == 200
|
||||
assert _row(sid_ora) in resp.text, (
|
||||
"Timestamp-ul cu ora trebuie inclus la filtrare pe ziua respectiva"
|
||||
)
|
||||
assert _row(sid_alt) not in resp.text, (
|
||||
"Randul din 2026-06-21 nu trebuie sa apara in intervalul 2026-06-20 to 2026-06-20"
|
||||
)
|
||||
|
||||
|
||||
def test_filtru_data_interval_inclusiv_capete(client):
|
||||
"""Intervalul data_de..data_pana este inclusiv la ambele capete.
|
||||
data_de=2026-06-10, data_pana=2026-06-12 → randurile din 10, 11, 12 apar;
|
||||
cel din 09 si cel din 13 nu apar.
|
||||
Testat si cu timestamp-uri (ISO cu ora) pentru a combina ambele cerinte."""
|
||||
acct = _create_account_user("interval@test.com")
|
||||
sid_09 = _ins(acct, vin="WVIN_INTERVAL_09000", nr="B09", data="2026-06-09")
|
||||
sid_10 = _ins(acct, vin="WVIN_INTERVAL_10000", nr="B10", data="2026-06-10") # capat stang inclusiv
|
||||
sid_11 = _ins(acct, vin="WVIN_INTERVAL_11000", nr="B11", data="2026-06-11 08:00:00") # mijloc cu ora
|
||||
sid_12 = _ins(acct, vin="WVIN_INTERVAL_12000", nr="B12", data="2026-06-12T23:59:59") # capat drept cu T
|
||||
sid_13 = _ins(acct, vin="WVIN_INTERVAL_13000", nr="B13", data="2026-06-13")
|
||||
_login(client, "interval@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-10&data_pana=2026-06-12")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert _row(sid_09) not in body, "2026-06-09 e inainte de data_de → nu trebuie sa apara"
|
||||
assert _row(sid_10) in body, "2026-06-10 = data_de → capatul stang inclusiv"
|
||||
assert _row(sid_11) in body, "2026-06-11 cu ora trebuie inclus"
|
||||
assert _row(sid_12) in body, "2026-06-12 cu T trebuie inclus (capatul drept inclusiv)"
|
||||
assert _row(sid_13) not in body, "2026-06-13 e dupa data_pana → nu trebuie sa apara"
|
||||
|
||||
|
||||
def test_filtru_data_ignora_valori_ne_data(client):
|
||||
"""Valorile care nu incep cu o data ISO valida sunt excluse din rezultate
|
||||
cand filtrul de data e activ — comportamentul actual pastrat."""
|
||||
acct = _create_account_user("nedata@test.com")
|
||||
sid_dd = _ins(acct, vin="WVIN_NEDATA_DD000001", nr="BND1", data="20.06.2026") # format DD.MM.YYYY — ne-ISO
|
||||
sid_en = _ins(acct, vin="WVIN_NEDATA_EN000002", nr="BND2", data="Jun 20 2026") # format englezesc — ne-ISO
|
||||
sid_bun = _ins(acct, vin="WVIN_NEDATA_BUNA0003", nr="BGD", data="2026-06-20") # format corect ISO
|
||||
_login(client, "nedata@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-20&data_pana=2026-06-20")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert _row(sid_dd) not in body, "Format DD.MM.YYYY trebuie exclus (ne-ISO)"
|
||||
assert _row(sid_en) not in body, "Format englezesc trebuie exclus (ne-ISO)"
|
||||
assert _row(sid_bun) in body, "Format ISO corect trebuie inclus"
|
||||
198
tests/test_web_header_branding.py
Normal file
198
tests/test_web_header_branding.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Teste US-012 / US-012b / US-012c (PRD 5.10): Header logo ROMFAST + titlu centrat.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
US-012b (decizie user): logo PNG real (/static/romfast_logo.png) in loc de wordmark text.
|
||||
US-012c (decizie user): logo mutat din header-center in header-left (brand top-left ca pe romfast.ro).
|
||||
Decizie env badge: mutat in header-center (sub <h1>, mic, color:var(--muted)) — nu suprapune
|
||||
logo-ul si pastreaza centrarea optica a titlului in coloana auto a grilei.
|
||||
|
||||
Testeaza:
|
||||
- test_header_contine_by_romfast: img brand-logo in .header-left (NU in header-center)
|
||||
- test_titlu_centrat: titlul e in .header-center (grila 3 coloane), controale la dreapta
|
||||
"""
|
||||
|
||||
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, "branding.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 _get_header(html: str) -> str:
|
||||
"""Extrage continutul elementului <header>."""
|
||||
m = re.search(r"<header>(.*?)</header>", html, re.DOTALL | re.IGNORECASE)
|
||||
assert m, "<header> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _get_style(html: str) -> str:
|
||||
"""Extrage continutul primului <style>."""
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _get_div_content(html: str, cls: str) -> str | None:
|
||||
"""Extrage continutul primului div cu clasa `cls` (non-nested)."""
|
||||
m = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*' + re.escape(cls) + r'[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# ── test_header_contine_by_romfast ────────────────────────────────────────────
|
||||
|
||||
def test_header_contine_by_romfast(client):
|
||||
"""Logo-ul ROMFAST (<img class='brand-logo'>) trebuie sa fie in .header-LEFT (US-012c).
|
||||
|
||||
Decizie user: brand top-left ca pe romfast.ro — logo in prima celula a grilei, nu sub titlu.
|
||||
|
||||
Verifica:
|
||||
- img cu romfast_logo.png SI class brand-logo exista in interiorul .header-left
|
||||
- img cu romfast_logo.png NU exista in interiorul .header-center (a fost mutat)
|
||||
- alt non-gol pe img (accesibilitate)
|
||||
- .header-center NU mai contine clasele .romfast-rom / .romfast-fast (curatenie)
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
header = _get_header(resp.text)
|
||||
|
||||
# Extrage continutul .header-left si .header-center
|
||||
left_content = _get_div_content(header, "header-left")
|
||||
center_content = _get_div_content(header, "header-center")
|
||||
assert left_content is not None, ".header-left lipseste din <header>"
|
||||
assert center_content is not None, ".header-center lipseste din <header>"
|
||||
|
||||
# 1. img cu romfast_logo.png IN .header-left
|
||||
img_tags_left = re.findall(r'<img[^>]+>', left_content, re.IGNORECASE)
|
||||
logo_in_left = next(
|
||||
(t for t in img_tags_left if "romfast_logo.png" in t),
|
||||
None,
|
||||
)
|
||||
assert logo_in_left is not None, (
|
||||
"<img> cu 'romfast_logo.png' negasit in .header-left. "
|
||||
"US-012c: logo-ul trebuie sa fie in celula STANGA a grilei (brand top-left). "
|
||||
f"Continut .header-left: {left_content[:400]}"
|
||||
)
|
||||
|
||||
# 2. img cu romfast_logo.png NU mai e in .header-center
|
||||
img_tags_center = re.findall(r'<img[^>]+>', center_content, re.IGNORECASE)
|
||||
logo_in_center = next(
|
||||
(t for t in img_tags_center if "romfast_logo.png" in t),
|
||||
None,
|
||||
)
|
||||
assert logo_in_center is None, (
|
||||
"<img> cu 'romfast_logo.png' inca e in .header-center — trebuie mutat in .header-left. "
|
||||
f"Continut .header-center: {center_content[:400]}"
|
||||
)
|
||||
|
||||
# 3. alt non-gol pe logo (accesibilitate)
|
||||
alt_match = re.search(r'alt=["\']([^"\']+)["\']', logo_in_left, re.IGNORECASE)
|
||||
assert alt_match and alt_match.group(1).strip(), (
|
||||
"Imaginea logo din .header-left lipseste atributul alt (sau e gol). "
|
||||
f"Tag gasit: {logo_in_left}"
|
||||
)
|
||||
|
||||
# 4. class brand-logo prezent pe img
|
||||
assert "brand-logo" in logo_in_left, (
|
||||
"class='brand-logo' lipseste de pe <img> logo din .header-left. "
|
||||
f"Tag gasit: {logo_in_left}"
|
||||
)
|
||||
|
||||
# 5. Spanurile text (wordmark vechi) NU exista in header
|
||||
assert "romfast-rom" not in header and "romfast-fast" not in header, (
|
||||
"Clasele .romfast-rom / .romfast-fast (wordmark text) inca prezente in header. "
|
||||
f"Header snippet: {header[:500]}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_titlu_centrat ────────────────────────────────────────────────────────
|
||||
|
||||
def test_titlu_centrat(client):
|
||||
"""Titlul 'Gateway RAR AUTOPASS' e in structura centrata in header (grila 3 coloane).
|
||||
|
||||
Verifica:
|
||||
- CSS contine grid-template-columns cu 3 coloane pe header (1fr auto 1fr sau similar)
|
||||
- Header contine un element cu clasa 'header-center' (sau similar) care contine h1
|
||||
- Controalele (button tema-toggle) sunt la dreapta (in header-right sau margin-left:auto)
|
||||
- header-left exista (celula stanga a grilei, contine logo dupa US-012c)
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
header = _get_header(html)
|
||||
style = _get_style(html)
|
||||
|
||||
# 1. CSS header foloseste grid cu 3 coloane
|
||||
header_css = re.search(r"header\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert header_css, "Regula CSS 'header { ... }' negasita in <style>"
|
||||
header_block = header_css.group(1)
|
||||
assert "grid" in header_block.lower(), (
|
||||
f"header CSS nu foloseste grid (display:grid). Block: {header_block.strip()}"
|
||||
)
|
||||
assert "grid-template-columns" in style, (
|
||||
"grid-template-columns lipseste din <style> (necesar pentru 3 coloane)"
|
||||
)
|
||||
|
||||
# 2. Element centrat in header contine h1
|
||||
center_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-center[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert center_div, (
|
||||
"Element cu clasa 'header-center' negasit in <header>. "
|
||||
"Titlul trebuie sa fie intr-o celula centrata a grilei. "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
center_content = center_div.group(1)
|
||||
assert "<h1" in center_content, (
|
||||
"h1 nu e in interiorul elementului .header-center. "
|
||||
f"Continut .header-center: {center_content[:300]}"
|
||||
)
|
||||
|
||||
# 3. Controalele (butonul de tema) sunt in header-right
|
||||
right_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-right[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert right_div, (
|
||||
"Element cu clasa 'header-right' negasit in <header>. "
|
||||
"Controalele (tema, versiune, meniu) trebuie sa fie la dreapta. "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
right_content = right_div.group(1)
|
||||
assert "tema-toggle" in right_content, (
|
||||
"Butonul tema-toggle nu e in .header-right. "
|
||||
f"Continut .header-right: {right_content[:300]}"
|
||||
)
|
||||
|
||||
# 4. header-left exista in grila (contine logo dupa US-012c)
|
||||
left_div = re.search(
|
||||
r'<div[^>]+class=["\'][^"\']*header-left[^"\']*["\'][^>]*>(.*?)</div>',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert left_div, (
|
||||
"Element cu clasa 'header-left' negasit in <header>. "
|
||||
f"Header snippet: {header[:600]}"
|
||||
)
|
||||
141
tests/test_web_mapari_actiuni.py
Normal file
141
tests/test_web_mapari_actiuni.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Teste US-011 (PRD 5.10): butoane icon salvare/stergere + dirty state pe Mapari.
|
||||
|
||||
Cerinte:
|
||||
- Butoane .icon-btn mereu vizibile pe rand (nu ascunse in kebab)
|
||||
- Meniu kebab (<details class="kebab">) eliminat
|
||||
- aria-label descriptiv pe fiecare buton icon
|
||||
- data-dirty-form pe butonul de salvare (permite JS dirty-state)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _seed_saved_mapping(acct_id: int) -> None:
|
||||
"""Insereaza o mapare salvata in operations_mapping."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(acct_id, "OP-TEST-77", "OE-1", 1),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_actiuni.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 test_butoane_icon_vizibile_pe_rand_salvate(client):
|
||||
"""Butoanele de salvare/stergere in 'Mapari salvate' au clasa icon-btn (mereu vizibile)."""
|
||||
acct = _create_account_user("actiuni_icon@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_icon@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'class="icon-btn' in html, (
|
||||
"Butoanele de actiune din 'Mapari salvate' trebuie sa aiba clasa 'icon-btn' "
|
||||
"(mereu vizibile pe rand, nu ascunse in kebab)."
|
||||
)
|
||||
|
||||
|
||||
def test_fara_kebab_meniu(client):
|
||||
"""Meniul kebab (details.kebab / kebab-menu) e eliminat din 'Mapari salvate'."""
|
||||
acct = _create_account_user("actiuni_kebab@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_kebab@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'class="kebab"' not in html, (
|
||||
"Meniul kebab (details.kebab) trebuie eliminat din 'Mapari salvate' (US-011)."
|
||||
)
|
||||
assert '"kebab-menu"' not in html, (
|
||||
"Clasa 'kebab-menu' trebuie eliminata complet din 'Mapari salvate' (US-011)."
|
||||
)
|
||||
|
||||
|
||||
def test_butoane_cu_aria_label(client):
|
||||
"""Butoanele icon-btn au aria-label descriptiv."""
|
||||
acct = _create_account_user("actiuni_aria@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_aria@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
icon_btns = re.findall(r'<button[^>]+class="icon-btn[^"]*"[^>]*>', html)
|
||||
assert icon_btns, "Trebuie sa existe butoane cu clasa icon-btn in 'Mapari salvate'."
|
||||
assert any('aria-label' in btn for btn in icon_btns), (
|
||||
"Cel putin un buton icon-btn trebuie sa aiba atributul aria-label descriptiv."
|
||||
)
|
||||
|
||||
|
||||
def test_dirty_state_data_attr(client):
|
||||
"""Butonul de salvare are data-dirty-form pentru dirty-state JS."""
|
||||
acct = _create_account_user("actiuni_dirty@test.com")
|
||||
_seed_saved_mapping(acct)
|
||||
_login(client, "actiuni_dirty@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
assert 'data-dirty-form=' in html, (
|
||||
"Butonul de salvare trebuie sa aiba atributul data-dirty-form pentru JS dirty-state. "
|
||||
"Cand utilizatorul schimba selectul, JS adauga clasa 'dirty' pe buton (fundal --accent)."
|
||||
)
|
||||
135
tests/test_web_mapari_layout.py
Normal file
135
tests/test_web_mapari_layout.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Teste US-010 (PRD 5.10): restructurare pagina Mapari intr-o singura pagina consolidata.
|
||||
|
||||
Modificari cerute:
|
||||
- Ordinea sectiunilor: (1) De rezolvat, (2) Mapari salvate, (3) Reguli automate, (4) Formate coloane.
|
||||
- Sectiunea de ajutor (<details class="ajutor-mapari">) eliminata.
|
||||
- Textul empty-state „Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR..." eliminat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_layout.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 test_de_rezolvat_prima(client):
|
||||
"""Sectiunea „De rezolvat" apare prima — inaintea „Mapari operatii salvate"."""
|
||||
_create_account_user("mapari_ord@test.com")
|
||||
_login(client, "mapari_ord@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
pos_de_rezolvat = html.find("De rezolvat")
|
||||
pos_salvate = html.find("Mapari operatii salvate")
|
||||
|
||||
assert pos_de_rezolvat != -1, "Sectiunea 'De rezolvat' trebuie sa existe in pagina."
|
||||
assert pos_salvate != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe in pagina."
|
||||
assert pos_de_rezolvat < pos_salvate, (
|
||||
f"'De rezolvat' (pozitia {pos_de_rezolvat}) trebuie sa apara INAINTE de "
|
||||
f"'Mapari operatii salvate' (pozitia {pos_salvate})."
|
||||
)
|
||||
|
||||
|
||||
def test_fara_ajutor_si_empty_text(client):
|
||||
"""Sectiunea de ajutor (ajutor-mapari) si empty-text-ul specific sunt eliminate."""
|
||||
_create_account_user("mapari_fara@test.com")
|
||||
_login(client, "mapari_fara@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Sectiunea de ajutor eliminata
|
||||
assert 'ajutor-mapari' not in html, (
|
||||
"Clasa 'ajutor-mapari' (details de ajutor) trebuie eliminata din pagina Mapari (US-010)."
|
||||
)
|
||||
assert 'class="ajutor-mapari"' not in html, (
|
||||
"<details class=\"ajutor-mapari\"> trebuie eliminat."
|
||||
)
|
||||
|
||||
# Empty-text specific „Nicio operatie nemapata" eliminat
|
||||
assert "Nicio operatie nemapata" not in html, (
|
||||
"Textul empty-state 'Nicio operatie nemapata — tot ce a venit...' trebuie eliminat (US-010)."
|
||||
)
|
||||
assert "tot ce a venit s-a tradus in coduri RAR" not in html, (
|
||||
"Textul empty-state extins trebuie eliminat complet."
|
||||
)
|
||||
|
||||
|
||||
def test_ordine_sectiuni(client):
|
||||
"""Ordinea corecta a sectiunilor: De rezolvat → Mapari salvate → Reguli automate → Formate."""
|
||||
_create_account_user("mapari_ord2@test.com")
|
||||
_login(client, "mapari_ord2@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
pos1 = html.find("De rezolvat")
|
||||
pos2 = html.find("Mapari operatii salvate")
|
||||
pos3 = html.find("Reguli automate")
|
||||
pos4 = html.find("Formate de coloane")
|
||||
|
||||
assert pos1 != -1, "Sectiunea 'De rezolvat' trebuie sa existe."
|
||||
assert pos2 != -1, "Sectiunea 'Mapari operatii salvate' trebuie sa existe."
|
||||
assert pos3 != -1, "Sectiunea 'Reguli automate' trebuie sa existe."
|
||||
assert pos4 != -1, "Sectiunea 'Formate de coloane' trebuie sa existe."
|
||||
|
||||
assert pos1 < pos2, "De rezolvat trebuie sa fie inaintea Mapari salvate."
|
||||
assert pos2 < pos3, (
|
||||
f"'Mapari operatii salvate' (poz {pos2}) trebuie sa fie inaintea "
|
||||
f"'Reguli automate' (poz {pos3}). Acum Reguli automate e ultima sectiune — "
|
||||
"muta-o pe pozitia 3 (inaintea Formate de coloane)."
|
||||
)
|
||||
assert pos3 < pos4, (
|
||||
f"'Reguli automate' (poz {pos3}) trebuie sa fie inaintea "
|
||||
f"'Formate de coloane' (poz {pos4})."
|
||||
)
|
||||
150
tests/test_web_mapari_meniu.py
Normal file
150
tests/test_web_mapari_meniu.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Teste US-009 (PRD 5.10): Mapari in meniu hamburger + scoatere tab-uri.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare.
|
||||
|
||||
Acceptance criteria testate:
|
||||
- test_meniu_contine_mapari: meniul #cont-menu are o intrare Mapari
|
||||
(href=/?tab=mapari); badge vizibil cand exista needs_mapping.
|
||||
- test_pagina_principala_fara_tabbar_mapari: pagina / nu mai are role="tablist"
|
||||
(tab-bar-ul Acasa/Mapari a fost eliminat).
|
||||
- test_ruta_mapari_randeaza_sectiunea: GET /?tab=mapari → 200, sectiunea mapari
|
||||
randata (id="mapari-section"), fara role="tablist" rezidual.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, password: str = "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 Meniu", active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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 pe /login"
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins_needs_mapping(acct: int) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?)",
|
||||
(f"k-us009-{os.urandom(4).hex()}", acct, json.dumps({"prestatii": [{"cod_op_service": "X"}]})),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "meniu_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 test_meniu_contine_mapari(client):
|
||||
"""Meniul hamburger (#cont-menu) contine o intrare cu href=/?tab=mapari."""
|
||||
_create_account_user("menu1@test.com")
|
||||
_login(client, "menu1@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
# Intrarea Mapari trebuie sa existe in meniu
|
||||
assert 'href="/?tab=mapari"' in html, (
|
||||
"Meniul hamburger trebuie sa contina o intrare cu href='/?tab=mapari'"
|
||||
)
|
||||
# Textul "Mapari" trebuie sa apara in meniu (in apropierea link-ului)
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1
|
||||
# Cauta "Mapari" in fereastra contextului link-ului
|
||||
window = html[max(0, idx - 50):idx + 100]
|
||||
assert "Mapari" in window, (
|
||||
f"Textul 'Mapari' trebuie sa apara langa href=/?tab=mapari: ...{window}..."
|
||||
)
|
||||
|
||||
|
||||
def test_meniu_badge_needs_mapping(client):
|
||||
"""Badge vizibil in meniu cand exista submissions needs_mapping."""
|
||||
acct = _create_account_user("menu2@test.com")
|
||||
_ins_needs_mapping(acct)
|
||||
_login(client, "menu2@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
# Badgeul trebuie sa apara in apropierea intrarii Mapari
|
||||
idx = html.find('href="/?tab=mapari"')
|
||||
assert idx != -1, "Intrarea Mapari lipseste din meniu"
|
||||
# Cauta tab-badge in contextul intrarii Mapari (in tag-ul/blocul imediat urmator)
|
||||
window = html[idx:idx + 300]
|
||||
assert "tab-badge" in window, (
|
||||
"Badgeul (tab-badge) trebuie sa apara in intrarea Mapari cand exista needs_mapping"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_principala_fara_tabbar_mapari(client):
|
||||
"""Pagina principala / nu mai are role=tablist (tab-bar-ul eliminat in US-009)."""
|
||||
_create_account_user("menu3@test.com")
|
||||
_login(client, "menu3@test.com")
|
||||
|
||||
html = client.get("/").text
|
||||
|
||||
assert 'role="tablist"' not in html, (
|
||||
"Tab-bar-ul (role=tablist) trebuie eliminat din pagina principala (US-009)"
|
||||
)
|
||||
# Nici rolul de tab individual nu trebuie sa existe in tab-bar
|
||||
# (role=tab poate exista in alte contexte, dar tab-bar-ul tablist+tab nu)
|
||||
assert 'class="tab-bar"' not in html, (
|
||||
"Clasa CSS tab-bar trebuie eliminata din pagina principala (US-009)"
|
||||
)
|
||||
|
||||
|
||||
def test_ruta_mapari_randeaza_sectiunea(client):
|
||||
"""GET /?tab=mapari → 200, sectiunea mapari randata, fara tablist rezidual."""
|
||||
_create_account_user("menu4@test.com")
|
||||
_login(client, "menu4@test.com")
|
||||
|
||||
resp = client.get("/?tab=mapari")
|
||||
|
||||
assert resp.status_code == 200, (
|
||||
f"/?tab=mapari trebuie sa returneze 200, nu {resp.status_code}"
|
||||
)
|
||||
html = resp.text
|
||||
assert 'id="mapari-section"' in html, (
|
||||
"Sectiunea mapari (id='mapari-section') trebuie randata la /?tab=mapari"
|
||||
)
|
||||
assert 'role="tablist"' not in html, (
|
||||
"Tab-bar-ul (role=tablist) nu trebuie sa apara nici la /?tab=mapari"
|
||||
)
|
||||
@@ -161,58 +161,48 @@ def test_modal_hookuri_js_prezente(client):
|
||||
assert "window.inchideDetaliu" in js
|
||||
|
||||
|
||||
# --- PRD 5.9 US-005 (R6): poll-guard ---------------------------------------
|
||||
# Modalul + selectia trebuie sa supravietuiasca poll-ului de 15s. Logica e JS in
|
||||
# base.html: testam la nivel de markup/handler ca guard-ul exista si distinge corect
|
||||
# sursa trigger-ului (periodic vs trimiteriChanged/filtru). Comportamentul runtime
|
||||
# efectiv (anularea propriu-zisa) e validat E2E (requiresBrowserCheck) — aici asertam
|
||||
# codul/atributele care il implementeaza.
|
||||
# --- Tabelul nu se reincarca singur: modalul + selectia sunt sigure ---------
|
||||
# Tabelul (#submissions-wrap) nu mai are poll periodic; se reincarca DOAR la load,
|
||||
# la actiunile utilizatorului (trimiteriChanged) sau la apasarea pe Reincarca (nudge).
|
||||
# Asa, modalul deschis si bifele de bulk nu pot fi sterse de un timer.
|
||||
|
||||
|
||||
def test_poll_pauzat_cat_modal_deschis(client):
|
||||
"""Guard-ul de poll exista si, cat modalul de detaliu e deschis, anuleaza
|
||||
reincarcarea periodica a listei (#submissions-wrap), nu pe restul."""
|
||||
_create_account_user("poll1@test.com")
|
||||
def test_tabel_fara_poll_periodic(client):
|
||||
"""#submissions-wrap nu are trigger periodic (`every Ns`) — niciun timer nu poate
|
||||
reseta modalul deschis sau selectia de bulk in timpul interactiunii."""
|
||||
acct = _create_account_user("poll1@test.com")
|
||||
_login(client, "poll1@test.com")
|
||||
js = client.get("/?tab=acasa").text
|
||||
_insert_submission(acct)
|
||||
html = client.get("/?tab=acasa").text
|
||||
|
||||
# Guard scopat la poll-ul listei, declansat pe htmx:beforeRequest.
|
||||
assert "htmx:beforeRequest" in js
|
||||
assert "d.elt.id !== 'submissions-wrap'" in js, "guard-ul trebuie scopat la #submissions-wrap"
|
||||
# Conditia (a): modal deschis -> pauza (preventDefault).
|
||||
assert "modalDeschis" in js
|
||||
assert "modal-detaliu" in js and "hidden" in js
|
||||
assert "evt.preventDefault()" in js, "pauza scopata se face prin preventDefault"
|
||||
assert 'id="submissions-wrap"' in html
|
||||
wrap = html[html.find('id="submissions-wrap"'):]
|
||||
wrap = wrap[:wrap.find(">") + 1]
|
||||
assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
|
||||
|
||||
|
||||
def test_poll_pauzat_cat_exista_bifa(client):
|
||||
"""Conditia (b): macar un checkbox de bulk bifat -> poll-ul periodic e pus pe
|
||||
pauza. Resume pe checkbox `change` prin delegare pe body (prinde si bifele
|
||||
randate dupa swap)."""
|
||||
_create_account_user("poll2@test.com")
|
||||
def test_nudge_date_noi_in_loc_de_poll(client):
|
||||
"""Reimprospatarea live se face prin nudge-ul 'Date noi' (poller usor de versiune)
|
||||
care NU atinge tabelul; utilizatorul reincarca explicit cand vrea."""
|
||||
acct = _create_account_user("poll2@test.com")
|
||||
_login(client, "poll2@test.com")
|
||||
js = client.get("/?tab=acasa").text
|
||||
_insert_submission(acct)
|
||||
html = client.get("/?tab=acasa").text
|
||||
|
||||
# Detecteaza bifa de bulk in interiorul #submissions-wrap.
|
||||
assert "existaBifa" in js
|
||||
assert 'input[name="submission_id"]:checked' in js
|
||||
# Resume: delegare pe body pe evenimentul `change` al checkbox-ului de bulk.
|
||||
assert "addEventListener('change'" in js
|
||||
assert "t.name === 'submission_id'" in js
|
||||
assert 'id="nudge-trimiteri"' in html, "bannerul nudge 'Date noi' trebuie sa existe"
|
||||
assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat"
|
||||
assert "reincarcaTrimiteri" in html, "reincarcarea manuala (Reincarca) trebuie expusa"
|
||||
|
||||
|
||||
def test_trimiteriChanged_inca_reincarca_cu_bifa(client):
|
||||
"""R6 (F5): guard-ul NU anuleaza request-urile cu `triggeringEvent`
|
||||
(trimiteriChanged / submit filtru) — acelea TREC MEREU, ca pauza sa nu ramana
|
||||
lipita permanent daca randul bifat paraseste filtrul."""
|
||||
_create_account_user("poll3@test.com")
|
||||
def test_trimiteriChanged_inca_reincarca(client):
|
||||
"""Actiunile utilizatorului (corectie / stergere) reincarca tabelul prin canalul
|
||||
`trimiteriChanged`, pastrand filtrul curent (hx-include #filtre-trimiteri)."""
|
||||
acct = _create_account_user("poll3@test.com")
|
||||
_login(client, "poll3@test.com")
|
||||
js = client.get("/?tab=acasa").text
|
||||
_insert_submission(acct)
|
||||
html = client.get("/?tab=acasa").text
|
||||
|
||||
# Numai trigger-ul periodic (fara triggeringEvent) e candidat la pauza;
|
||||
# orice request cu triggeringEvent iese devreme din guard.
|
||||
assert "triggeringEvent" in js
|
||||
assert "rc.triggeringEvent) return" in js, \
|
||||
"request-urile cu triggeringEvent (trimiteriChanged/filtru) trebuie sa treaca mereu"
|
||||
# Resume explicit reutilizeaza acelasi canal `trimiteriChanged` (pastreaza filtrul).
|
||||
assert "trimiteriChanged" in js
|
||||
wrap = html[html.find('id="submissions-wrap"'):]
|
||||
wrap = wrap[:wrap.find(">") + 1]
|
||||
assert "trimiteriChanged from:body" in wrap, "tabelul trebuie sa reincarce pe trimiteriChanged"
|
||||
assert 'hx-include="#filtre-trimiteri"' in wrap, "reincarcarea trebuie sa pastreze filtrul"
|
||||
|
||||
182
tests/test_web_paginare_submissions.py
Normal file
182
tests/test_web_paginare_submissions.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Teste US-004 (PRD 5.10): paginare numerotata pe tabelul de trimiteri.
|
||||
|
||||
Cazuri:
|
||||
- pagina_implicita_25: 30 trimiteri → pagina 1 afiseaza max 25
|
||||
- pagina_2_offset: 30 trimiteri, page=2 → 5 randuri
|
||||
- total_si_numar_pagini: raspunsul contine totalul + aria-current pe pagina curenta
|
||||
- paginarea_pastreaza_filtrele: linkurile de paginare includ filtrul status activ
|
||||
- pagina_peste_total_revine_la_ultima: page=99 cu 30 trimiteri → clamped la pagina 2
|
||||
- poll_pastreaza_pagina: raspunsul include id='f-page' value='2' (OOB) pentru poll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins_n(acct: int, n: int, status: str = "sent") -> None:
|
||||
"""Insereaza n submissions pentru contul dat."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-pg-{os.urandom(6).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": f"WVIN_PG_{i:04d}_DUMMY",
|
||||
"nr_inmatriculare": f"B{i:03d}PG",
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "paginare.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 test_pagina_implicita_25(client):
|
||||
"""Cu 30 trimiteri, pagina 1 (implicita) returneaza exact 25 randuri."""
|
||||
acct = _create_account_user("pg1@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg1@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 25, (
|
||||
f"Pagina 1 cu 30 trimiteri trebuie sa arate exact 25 randuri, nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_2_offset(client):
|
||||
"""Cu 30 trimiteri, page=2 returneaza restul de 5 randuri (offset 25)."""
|
||||
acct = _create_account_user("pg2@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg2@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 5, (
|
||||
f"Pagina 2 cu 30 total trebuie sa arate 5 randuri, nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_total_si_numar_pagini(client):
|
||||
"""Raspunsul contine totalul (30) si marcheaza pagina curenta cu aria-current='page'."""
|
||||
acct = _create_account_user("pg3@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg3@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Totalul trebuie afisat undeva (ex. "30 din 30" sau "afiseaza 1-25 din 30")
|
||||
assert "30" in body, "Totalul 30 trebuie sa apara in raspuns"
|
||||
# Pagina curenta e marcata semantic
|
||||
assert 'aria-current="page"' in body, (
|
||||
"Pagina curenta trebuie marcata cu aria-current='page'"
|
||||
)
|
||||
|
||||
|
||||
def test_paginarea_pastreaza_filtrele(client):
|
||||
"""Linkurile de paginare pastreaza filtrul status activ in URL."""
|
||||
acct = _create_account_user("pg4@test.com")
|
||||
_ins_n(acct, 30, status="needs_data")
|
||||
_login(client, "pg4@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?status=needs_data&page=1")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Pager-ul exista si linkurile contin status=needs_data
|
||||
assert "status=needs_data" in body, (
|
||||
"Linkurile de paginare trebuie sa pastreze filtrul status=needs_data"
|
||||
)
|
||||
|
||||
|
||||
def test_pagina_peste_total_revine_la_ultima(client):
|
||||
"""page=99 cu 30 trimiteri se clampeaza la ultima pagina (page 2 → 5 randuri)."""
|
||||
acct = _create_account_user("pg5@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg5@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=99")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
row_count = body.count('id="trimitere-row-')
|
||||
assert row_count == 5, (
|
||||
f"page=99 cu 30 trimiteri trebuie clamped la ultima pagina (5 randuri), nu {row_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_poll_pastreaza_pagina(client):
|
||||
"""Raspunsul de la page=2 include id='f-page' value='2' (OOB swap) pentru poll.
|
||||
|
||||
Mecanismul: _submissions.html include un element cu id='f-page' si hx-swap-oob='true'
|
||||
care actualizeaza inputul ascuns din #filtre-trimiteri. Poll-ul de 15s (hx-include=
|
||||
'#filtre-trimiteri') include astfel pagina curenta la urmatoarea iteratie (L2 PRD).
|
||||
"""
|
||||
acct = _create_account_user("pg6@test.com")
|
||||
_ins_n(acct, 30)
|
||||
_login(client, "pg6@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?page=2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Elementul OOB trebuie sa fie in raspuns cu valoarea corecta
|
||||
assert 'id="f-page"' in body, (
|
||||
"Raspunsul trebuie sa includa id='f-page' (OOB swap) pentru ca poll-ul sa pastreze pagina"
|
||||
)
|
||||
assert 'value="2"' in body, (
|
||||
"Elementul f-page trebuie sa aiba value='2' cand page=2"
|
||||
)
|
||||
171
tests/test_web_pill_filtre.py
Normal file
171
tests/test_web_pill_filtre.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Teste US-003 (PRD 5.10): pill-uri de filtrare per categorie in bara de status.
|
||||
|
||||
Cazuri:
|
||||
- pill_per_categorie_cu_numar: pill-uri <button> cu numarul corect per categorie
|
||||
- pill_click_seteaza_status: pill-urile au atributele HTMX corecte (status=X, aria-pressed)
|
||||
- fara_lista_id_uri: lista de ID-uri/VIN-uri nu mai apare in bara de status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, status: str, vin: str = "WVIN000000000001", nr: str = "B001") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pill.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 test_pill_per_categorie_cu_numar(client):
|
||||
"""Bara de status afiseaza pill-uri <button> cu numarul corect per categorie blocata."""
|
||||
acct = _create_account_user("pill1@test.com")
|
||||
# 2x needs_data, 1x needs_mapping, 1x error, 1x sent (fara pill)
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND1_001", nr="BND1")
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND1_002", nr="BND2")
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM1_001", nr="BNM1")
|
||||
_ins(acct, status="error", vin="WVIN_ER1_001", nr="BER1")
|
||||
_ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1")
|
||||
_login(client, "pill1@test.com")
|
||||
|
||||
# Pill-urile traiesc in bara de filtre din sectiunea Trimiteri.
|
||||
resp = client.get("/?tab=acasa")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Pill-urile sunt elemente <button>
|
||||
assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>"
|
||||
|
||||
# Fiecare categorie problemativa apare ca pill
|
||||
assert 'data-status="needs_data"' in body, "Pill needs_data trebuie sa apara"
|
||||
assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa apara"
|
||||
assert 'data-status="error"' in body, "Pill error trebuie sa apara"
|
||||
|
||||
# Contoarele sunt afisate in pill-uri
|
||||
assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill"
|
||||
|
||||
# Starea 'sent' nu produce pill (nu e categorie de problema)
|
||||
assert 'data-status="sent"' not in body, "Nu trebuie pill pentru sent"
|
||||
|
||||
|
||||
def test_pill_click_seteaza_status(client):
|
||||
"""Pill-urile au atributele HTMX corecte: hx-get cu status=X si aria-pressed."""
|
||||
acct = _create_account_user("pill2@test.com")
|
||||
_ins(acct, status="needs_data", vin="WVIN_ND2_001", nr="BND_P2a")
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM2_001", nr="BNM_P2a")
|
||||
_ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a")
|
||||
_login(client, "pill2@test.com")
|
||||
|
||||
resp = client.get("/?tab=acasa")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Fiecare pill scrie campul de filtru prin filtreazaStare(this, 'X')
|
||||
assert "filtreazaStare(this, 'needs_data')" in body, "Pill needs_data trebuie sa apeleze filtreazaStare cu needs_data"
|
||||
assert "filtreazaStare(this, 'needs_mapping')" in body, "Pill needs_mapping trebuie sa apeleze filtreazaStare cu needs_mapping"
|
||||
assert "filtreazaStare(this, 'error')" in body, "Pill error trebuie sa apeleze filtreazaStare cu error"
|
||||
|
||||
# Pill-urile au aria-pressed pentru accesibilitate (WCAG)
|
||||
assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed"
|
||||
|
||||
# Filtrarea trece prin form-ul care targeteaza tabelul de trimiteri
|
||||
assert "submissions-wrap" in body and "_fragments/submissions" in body, (
|
||||
"Form-ul de filtre trebuie sa targeteze #submissions-wrap prin /_fragments/submissions"
|
||||
)
|
||||
|
||||
|
||||
def test_fara_lista_id_uri(client):
|
||||
"""Lista de ID-uri/VIN-uri (ex. #1 WVIN... / B...) nu mai apare in bara de status."""
|
||||
acct = _create_account_user("pill3@test.com")
|
||||
sid = _ins(acct, status="needs_data", vin="WVIN_ND3_UNIC001", nr="BND_P3")
|
||||
_login(client, "pill3@test.com")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Structura <li class="muted"> cu randuri blocate a disparut
|
||||
assert '<li class="muted"' not in body, (
|
||||
"Structura <li class='muted'> cu randuri blocate trebuie eliminata din bara de status"
|
||||
)
|
||||
# VIN-ul complet nu mai apare in bara de status (era partial, dar nici partial nu mai vrem)
|
||||
assert "WVIN_ND3_UNIC001" not in body, (
|
||||
"VIN-ul (sau partial) nu trebuie sa mai apara in bara de status"
|
||||
)
|
||||
|
||||
|
||||
def test_pill_needs_mapping_culoare_warn(client):
|
||||
"""Pill-ul 'Lipsa cod' (needs_mapping) foloseste --warn (chihlimbar), nu --err (DESIGN.md).
|
||||
|
||||
DESIGN.md §Componente: 'Lipsa cod = --warn'. Celelalte categorii (needs_data, error) = --err.
|
||||
"""
|
||||
acct = _create_account_user("warn@test.com")
|
||||
# Inseram DOAR needs_mapping — pentru a izola culoarea si a nu confunda cu --err
|
||||
_ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1")
|
||||
_login(client, "warn@test.com")
|
||||
|
||||
resp = client.get("/?tab=acasa")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
|
||||
# Pill-ul needs_mapping trebuie sa aiba culoarea --warn (nu --err)
|
||||
assert "var(--warn)" in body, (
|
||||
"Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente"
|
||||
)
|
||||
assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa fie prezent in bara de filtre"
|
||||
@@ -104,8 +104,6 @@ def test_modal_fullscreen_clasa_mobil(client):
|
||||
|
||||
# Exista un bloc media mobil care vizeaza modalul.
|
||||
assert "@media (max-width:767px)" in html
|
||||
# Markerul US-006 pentru modalul full-screen pe mobil.
|
||||
assert "US-006" in html
|
||||
# Dialogul ocupa tot ecranul pe mobil (latime/inaltime pline, fara border-radius lateral).
|
||||
mobil = html[html.find("@media (max-width:767px)"):]
|
||||
assert "100vw" in mobil or "width:100%" in mobil
|
||||
@@ -184,7 +182,6 @@ def test_tabele_continut_au_clasa_responsive(client):
|
||||
assert 'data-eticheta="Daca operatia contine"' in mapari
|
||||
# Regula de card e definita o data in base.html, scopata pe `.tabel-card`.
|
||||
assert ".tabel-card thead" in mapari
|
||||
assert "US-007" in mapari
|
||||
|
||||
# --- Jurnal = scroll contained. Cu un eveniment seedat, tabelul apare in `.tablewrap`,
|
||||
# NU ca `.tabel-card`. ---
|
||||
@@ -259,7 +256,6 @@ def test_acasa_fara_scroll_orizontal_mobil(client):
|
||||
assert 'id="import-section"' in html
|
||||
assert 'id="status-bar"' in html
|
||||
assert 'id="filtre-trimiteri"' in html
|
||||
assert "US-008" in html
|
||||
|
||||
mobil = html[html.find("@media (max-width:767px)"):]
|
||||
|
||||
|
||||
179
tests/test_web_selector_tema.py
Normal file
179
tests/test_web_selector_tema.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Teste US-014 (PRD 5.10): Selector de tema ciclic Light/Dark/Petrol/Auto.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
Testeaza:
|
||||
- test_petrol_theme_definit: [data-theme="petrol"] definit cu valorile din DESIGN.md
|
||||
- test_buton_cicleaza_temele: buton ciclic + anti-FOUC extins + aria-live
|
||||
"""
|
||||
|
||||
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, "selector.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 _get_style(html: str) -> str:
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _get_head(html: str) -> str:
|
||||
m = re.search(r"<head>(.*?)</head>", html, re.DOTALL | re.IGNORECASE)
|
||||
assert m, "<head> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── test_petrol_theme_definit ─────────────────────────────────────────────────
|
||||
|
||||
def test_petrol_theme_definit(client):
|
||||
"""[data-theme="petrol"] definit in CSS cu valorile din DESIGN.md:
|
||||
--bg:#0e1416, --card:#161e20, --ink:#e6e9ef, --muted:#8b93a7,
|
||||
--line:#232c2e, --accent:#0E7C7B, --ok:#2FBF8F, --warn:#E0A93B, --err:#E05D5D
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style(resp.text)
|
||||
|
||||
# Bloc CSS [data-theme="petrol"] prezent
|
||||
petrol_m = re.search(
|
||||
r'\[data-theme=["\']petrol["\']\]\s*\{([^}]+)\}',
|
||||
style,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert petrol_m, (
|
||||
'[data-theme="petrol"] { ... } negasit in <style>. '
|
||||
"Tema Petrol trebuie definita conform DESIGN.md."
|
||||
)
|
||||
petrol_block = petrol_m.group(1)
|
||||
|
||||
petrol_vars = {
|
||||
"--accent": "#0E7C7B",
|
||||
"--bg": "#0e1416",
|
||||
"--card": "#161e20",
|
||||
"--ink": "#e6e9ef",
|
||||
"--line": "#232c2e",
|
||||
"--ok": "#2FBF8F",
|
||||
"--warn": "#E0A93B",
|
||||
"--err": "#E05D5D",
|
||||
}
|
||||
for var, val in petrol_vars.items():
|
||||
assert val.lower() in petrol_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste din [data-theme=\"petrol\"]. "
|
||||
f"Block petrol: {petrol_block.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_buton_cicleaza_temele ────────────────────────────────────────────────
|
||||
|
||||
def test_buton_cicleaza_temele(client):
|
||||
"""Butonul de tema cicleaza Light->Dark->Petrol->Auto si are accesibilitate completa.
|
||||
|
||||
Verifica:
|
||||
1. Anti-FOUC extins: cunoaste 'petrol' si 'auto'; fallback definit pt. valori legacy
|
||||
2. JS-ul ciclului contine toate cele 4 teme in ordinea corecta
|
||||
3. aria-label pe buton include 'Tema:' + tema curenta + urmatoarea
|
||||
4. Regiune aria-live="polite" prezenta pentru anuntarea schimbarii
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
head = _get_head(html)
|
||||
|
||||
# 1. Anti-FOUC cunoaste 'petrol' si 'auto' (script in <head>, inainte de <style>)
|
||||
style_pos = head.find('<style>')
|
||||
assert style_pos >= 0, "<style> negasit in <head>"
|
||||
head_before_style = head[:style_pos]
|
||||
|
||||
assert 'petrol' in head_before_style, (
|
||||
"Scriptul anti-FOUC nu cunoaste tema 'petrol'. "
|
||||
"Trebuie extins sa enumere toate cele 4 teme (light/dark/petrol/auto)."
|
||||
)
|
||||
assert 'auto' in head_before_style, (
|
||||
"Scriptul anti-FOUC nu cunoaste tema 'auto'. "
|
||||
"Trebuie sa rezolve 'auto' la light/dark inainte de primul paint."
|
||||
)
|
||||
|
||||
# 2. Anti-FOUC are fallback pentru valori legacy/necunoscute (un set de valide)
|
||||
# Acceptam: un obiect/array VALID, sau un if care verifica valorile cunoscute
|
||||
has_valid_guard = (
|
||||
'VALID' in head_before_style
|
||||
or re.search(r'light.*dark.*petrol.*auto', head_before_style, re.DOTALL)
|
||||
or 'indexOf' in head_before_style
|
||||
)
|
||||
assert has_valid_guard, (
|
||||
"Anti-FOUC lipseste de un guard pentru valori legacy/necunoscute. "
|
||||
"O valoare 'localStorage.theme' necunoscuta trebuie sa cada pe 'auto' sau 'dark'."
|
||||
)
|
||||
|
||||
# 3. JS-ul din <body> contine ciclul complet Light->Dark->Petrol->Auto
|
||||
# Cautam in tot HTML-ul (nu doar head) prezenta tuturor celor 4 teme in JS
|
||||
# Acceptam: array explicit ['light','dark','petrol','auto'] sau logic echivalent
|
||||
cycle_match = re.search(
|
||||
r"['\"]light['\"].*['\"]dark['\"].*['\"]petrol['\"].*['\"]auto['\"]",
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert cycle_match, (
|
||||
"Ciclul Light->Dark->Petrol->Auto negasit in JS. "
|
||||
"Asteptat: array sau secventa cu toate cele 4 teme in ordine."
|
||||
)
|
||||
|
||||
# 4. aria-label pe butonul tema-toggle include 'Tema:' (format 'Tema: X, apasa pentru Y')
|
||||
tema_btn = re.search(
|
||||
r'<button[^>]+id=["\']tema-toggle["\'][^>]*>',
|
||||
html,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert tema_btn, "Butonul #tema-toggle negasit in HTML"
|
||||
btn_tag = tema_btn.group(0)
|
||||
# Acceptam aria-label setat initial in HTML SAU prin JS (aria-label din tag poate fi
|
||||
# placeholder; testam ca JS-ul contine formatul corect)
|
||||
has_tema_label = (
|
||||
'Tema:' in html
|
||||
or 'tema:' in html.lower()
|
||||
or re.search(r'aria-label[^>]*[Tt]ema', html)
|
||||
)
|
||||
assert has_tema_label, (
|
||||
"aria-label cu formatul 'Tema: ...' negasit in HTML/JS. "
|
||||
"Butonul trebuie sa anunte tema curenta + urmatoarea."
|
||||
)
|
||||
|
||||
# 5. Regiune aria-live="polite" prezenta (pentru anuntarea schimbarii de tema)
|
||||
assert 'aria-live="polite"' in html or "aria-live='polite'" in html, (
|
||||
'Regiune aria-live="polite" negasita in HTML. '
|
||||
"Necesara pentru a anunta schimbarea temei catre screen-readers."
|
||||
)
|
||||
|
||||
# 6. US-014b (decizie user): title-ul vizual al butonului = DOAR numele temei, NU ciclul.
|
||||
# Verificam in sursa JS ca variabila TOOLTIP_CICLU (sirul cu "Ciclu: Light → Dark → ...") nu
|
||||
# mai e folosita in title. aria-label-ul informativ (curenta+urmatoarea) RAMANE neschimbat.
|
||||
assert 'TOOLTIP_CICLU' not in html, (
|
||||
"Variabila TOOLTIP_CICLU inca prezenta in JS — title-ul trebuie simplificat. "
|
||||
"US-014b: btn.title trebuie sa fie DOAR LABELS[s] (ex. 'Light'), "
|
||||
"nu 'Tema: X. Ciclu: Light -> Dark -> Petrol -> Auto'. "
|
||||
"aria-label-ul informativ RAMANE neschimbat."
|
||||
)
|
||||
# Verifica ca JS-ul seteaza btn.title la doar LABELS[s] (fara prefix 'Tema:' sau ciclul)
|
||||
assert re.search(r"btn\.title\s*=\s*LABELS\[", html), (
|
||||
"btn.title nu e setat la LABELS[s] in JS. "
|
||||
"US-014b: formatul asteptat: btn.title = LABELS[s] (ex. rezultat: 'Petrol'). "
|
||||
"Aria-label-ul informativ ramane separat: 'Tema: X, apasa pentru Y'."
|
||||
)
|
||||
@@ -133,28 +133,17 @@ def test_status_blocate_defalcare(client):
|
||||
_insert_submission("needs_data", acct_id)
|
||||
_insert_submission("error", acct_id)
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
# Pill-urile s-au mutat in bara de filtre din sectiunea Trimiteri (nu in bara de status).
|
||||
resp = client.get("/?tab=acasa")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa arate titlul grupului de blocate
|
||||
assert "Necesita atentia ta" in html, (
|
||||
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
|
||||
)
|
||||
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
|
||||
assert "Lipseste codul prestatiei" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_mapping"
|
||||
)
|
||||
assert "Date incomplete" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_data"
|
||||
)
|
||||
assert "Eroare la trimitere" in html, (
|
||||
"Fragmentul nu arata eticheta pentru error"
|
||||
)
|
||||
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
|
||||
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
|
||||
# (nu strict format, ci prezenta datelor)
|
||||
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
|
||||
# Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
|
||||
assert "Lipsa cod" in html, "Acasa nu arata pill-ul pentru needs_mapping"
|
||||
assert "Date incomplete" in html, "Acasa nu arata pill-ul pentru needs_data"
|
||||
assert "Eroare" in html, "Acasa nu arata pill-ul pentru error"
|
||||
# Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
|
||||
assert 'class="pill-cat"' in html, "Pill-urile trebuie sa fie elemente cu clasa pill-cat"
|
||||
assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -208,26 +197,30 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
|
||||
|
||||
|
||||
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
|
||||
"""Pill-ul error filtreaza tabelul prin filtreazaStare(this, 'error') in bara de filtre."""
|
||||
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
|
||||
_login(client, "link@test.com", "parolasecreta10")
|
||||
_insert_submission("error", acct_id)
|
||||
|
||||
html = client.get("/_fragments/status").text
|
||||
# Link HTMX catre lista filtrata pe error + deep-link server-side
|
||||
assert "/_fragments/submissions?status=error" in html
|
||||
assert "tab=acasa&status=error" in html
|
||||
html = client.get("/?tab=acasa").text
|
||||
# Pill-ul scrie campul de filtru si re-trimite form-ul (nu mai navigheaza prin deep-link)
|
||||
assert "filtreazaStare(this, 'error')" in html
|
||||
assert 'data-status="error"' in html
|
||||
assert "tab=acasa&status=error" not in html
|
||||
|
||||
|
||||
def test_status_arata_identificator_rand_blocat(client):
|
||||
def test_status_nu_arata_identificator_rand_blocat(client):
|
||||
"""US-003 (PRD 5.10): VIN/nr inmatriculare nu mai apar in bara de status.
|
||||
Lista de ID-uri a fost inlocuita cu pill-uri cu numar total (fara PII nominal)."""
|
||||
acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
|
||||
_login(client, "ident@test.com", "parolasecreta10")
|
||||
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
|
||||
|
||||
html = client.get("/_fragments/status").text
|
||||
# VIN partial (ultimele 4) + nr inmatriculare + #id
|
||||
assert "0123" in html, "lipseste VIN partial"
|
||||
assert "B123ABC" in html, "lipseste nr inmatriculare"
|
||||
# Bara de status arata doar contoare, nu lista cu VIN/nr per rand (fara PII nominal)
|
||||
assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
|
||||
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
|
||||
assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status"
|
||||
|
||||
|
||||
def test_scoped_pe_cont(client):
|
||||
|
||||
151
tests/test_web_submissions_layout.py
Normal file
151
tests/test_web_submissions_layout.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Teste US-005 (PRD 5.10): VIN pe rand separat sub numarul de inmatriculare.
|
||||
|
||||
VIN-ul era randat ca <span> inline in aceeasi celula cu nr. Story-ul cere un
|
||||
element block-level (div/small/p cu display:block) sub nr, in stil muted.
|
||||
Testul asserteaza tipul elementului (block), nu doar prezenta textului.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True)
|
||||
create_user(conn, acct_id, email, password)
|
||||
return acct_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str = "parolasecreta10") -> 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
|
||||
resp = client.post(
|
||||
"/login",
|
||||
data={"email": email, "parola": password, "csrf_token": m.group(1)},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _ins(acct: int, *, vin: str = "", nr: str = "B01TST", status: str = "queued") -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"k-{os.urandom(5).hex()}", acct, status,
|
||||
json.dumps({
|
||||
"vin": vin,
|
||||
"nr_inmatriculare": nr,
|
||||
"data_prestatie": "2026-06-20",
|
||||
"odometru_final": "100",
|
||||
"prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "layout_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 test_vin_pe_rand_separat_sub_nr(client):
|
||||
"""VIN-ul apare intr-un element block-level (div/p/small cu display:block) sub nr.
|
||||
|
||||
Inainte: <span class="muted">...VIN...</span> inline.
|
||||
Dupa: <div class="muted">...VIN...</div> (block, rand separat).
|
||||
Testul asserteaza prezenta unui element block, nu doar textul.
|
||||
"""
|
||||
acct = _create_account_user("vin_layout@test.com")
|
||||
sid = _ins(acct, vin="WVWZZZ1JZXW000001", nr="B123XYZ")
|
||||
_login(client, "vin_layout@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# VIN trunchiat trebuie sa apara in HTML
|
||||
assert "000001" in html, "VIN-ul trunchiat trebuie sa apara in tabel"
|
||||
|
||||
# Elementul ce contine VIN-ul trebuie sa fie block-level (div, p, small etc.)
|
||||
# NU un simplu <span> inline.
|
||||
# Pattern: <div ... >...000001...</div> sau <p ... >...000001...</p>
|
||||
# Acceptam orice block-level tag (div/p/small) care contine fragmentul VIN.
|
||||
block_tags = ["div", "p", "small"]
|
||||
vin_fragment = "000001"
|
||||
found_block = any(
|
||||
re.search(
|
||||
rf"<{tag}[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>",
|
||||
html,
|
||||
)
|
||||
for tag in block_tags
|
||||
)
|
||||
assert found_block, (
|
||||
f"VIN '{vin_fragment}' trebuie sa fie intr-un element block-level "
|
||||
f"(div/p/small), nu intr-un <span> inline. HTML gasit: "
|
||||
+ html[max(0, html.find(vin_fragment) - 80):html.find(vin_fragment) + 80]
|
||||
)
|
||||
|
||||
# Elementul block trebuie sa aiba clasa 'muted' (stil discret)
|
||||
muted_block = any(
|
||||
re.search(
|
||||
rf'<{tag}[^>]*class="[^"]*muted[^"]*"[^>]*>[^<]*{re.escape(vin_fragment)}[^<]*</{tag}>',
|
||||
html,
|
||||
)
|
||||
for tag in block_tags
|
||||
)
|
||||
assert muted_block, (
|
||||
f"Elementul block cu VIN trebuie sa aiba clasa 'muted'"
|
||||
)
|
||||
|
||||
|
||||
def test_vin_lipsa_nu_genereaza_rand_gol(client):
|
||||
"""Cand VIN-ul lipseste (sau e EMPTY='—'), nu apare un element gol in celula Vehicul."""
|
||||
acct = _create_account_user("vin_gol@test.com")
|
||||
sid = _ins(acct, vin="", nr="B999TST") # VIN gol -> EMPTY="—"
|
||||
_login(client, "vin_gol@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Randul trebuie sa existe
|
||||
assert f'id="trimitere-row-{sid}"' in html
|
||||
|
||||
# In coloana vehicul nu trebuie sa apara un element block gol cu "—"
|
||||
# (garda != '—' exista deja, verifica ca e respectata)
|
||||
assert 'class="muted"' not in html.split('col-vehicul')[1].split('col-operatie')[0] or \
|
||||
'—' not in (html.split('col-vehicul')[1].split('col-operatie')[0]), \
|
||||
"Elementul muted din coloana Vehicul nu trebuie sa contina '—' (rand gol VIN)"
|
||||
@@ -73,7 +73,7 @@ def client(monkeypatch):
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
|
||||
"""US-009 (5.10): tab-bar-ul eliminat; Mapari mutat in meniul ☰; rutele raman valide."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
@@ -81,16 +81,15 @@ def test_dashboard_are_tabbar(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# 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
|
||||
# US-009: tab-bar-ul (role="tablist") a fost eliminat
|
||||
assert 'role="tablist"' not in html, "Tab-bar-ul (role=tablist) trebuie eliminat (US-009)"
|
||||
# Cont/Integrare/Nomenclator raman in meniu, nu ca 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
|
||||
f"'{label}' nu ar mai trebui sa fie un tab separat"
|
||||
# Mapari e acum in meniu (nu tab), cu link valid
|
||||
assert 'href="/?tab=mapari"' in html, "Lipseste link Mapari din meniu"
|
||||
# Cont/Nomenclator raman in meniu
|
||||
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
|
||||
|
||||
|
||||
@@ -99,7 +98,7 @@ def test_dashboard_are_tabbar(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tab_implicit_acasa(client):
|
||||
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
|
||||
"""US-009: fara ?tab=, pagina principala randeaza continutul Acasa (upload + sectiuni)."""
|
||||
_create_account_user("implicit@test.com", "parolasecreta10")
|
||||
_login(client, "implicit@test.com", "parolasecreta10")
|
||||
|
||||
@@ -107,13 +106,11 @@ def test_tab_implicit_acasa(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Tab-ul activ trebuie sa aiba aria-selected="true"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
|
||||
# Verificam ca Acasa e cel cu aria-selected=true
|
||||
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
|
||||
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
|
||||
"Tab-ul Acasa nu are aria-selected=true"
|
||||
# US-009: tab-bar eliminat, deci nu mai exista aria-selected pe tab-uri
|
||||
assert 'role="tablist"' not in html, "Tab-bar-ul trebuie eliminat (US-009)"
|
||||
# Continutul Acasa (status-bar + tab-panel cu continut Acasa) e randat direct
|
||||
assert 'id="status-bar"' in html, "Status-bar-ul trebuie sa fie prezent"
|
||||
assert 'id="tab-panel"' in html, "Panoul de continut (tab-panel) trebuie sa fie prezent"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -140,23 +137,22 @@ def test_deeplink_tab_import(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tab_activ_randat_server_side(client):
|
||||
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
|
||||
"""Panoul activ e in HTML-ul initial, randat server-side (nu doar HTMX dupa load)."""
|
||||
_create_account_user("serverside@test.com", "parolasecreta10")
|
||||
_login(client, "serverside@test.com", "parolasecreta10")
|
||||
|
||||
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
|
||||
# Acasa e randat server-side
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul trebuie sa aiba role="tabpanel"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
|
||||
# US-009: role="tabpanel" eliminat; continutul e in div#tab-panel fara rol ARIA de tabpanel
|
||||
assert 'id="tab-panel"' in html, "Containerul de continut tab-panel trebuie sa existe"
|
||||
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (US-009)"
|
||||
|
||||
# Import tab server-side
|
||||
# Import tab server-side: ?tab=import randeaza direct continutul Import
|
||||
resp2 = client.get("/?tab=import")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
|
||||
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
|
||||
|
||||
|
||||
@@ -205,7 +201,7 @@ def test_fragmentele_inactive_lazy(client):
|
||||
# ============================================================
|
||||
|
||||
def test_tabbar_aria(client):
|
||||
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
|
||||
"""US-009: schela ARIA orfana (role=tablist/tab/tabpanel/aria-selected) a fost eliminata."""
|
||||
_create_account_user("aria@test.com", "parolasecreta10")
|
||||
_login(client, "aria@test.com", "parolasecreta10")
|
||||
|
||||
@@ -213,11 +209,14 @@ def test_tabbar_aria(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
assert 'role="tab"' in html, "Lipseste role=tab"
|
||||
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"
|
||||
# US-009: un role="tablist" cu un singur tab e violare ARIA → eliminat
|
||||
assert 'role="tablist"' not in html, "role=tablist trebuie eliminat (US-009)"
|
||||
assert 'role="tab"' not in html, "role=tab trebuie eliminat (tab-bar eliminat)"
|
||||
assert 'role="tabpanel"' not in html, "role=tabpanel trebuie eliminat (tab-bar eliminat)"
|
||||
assert 'aria-selected=' not in html, "aria-selected trebuie eliminat (fara tab-uri)"
|
||||
# Meniu cont (role="menu") si item-urile sale (role="menuitem") raman valide
|
||||
assert 'role="menu"' in html, "Meniul hamburger (role=menu) trebuie pastrat"
|
||||
assert 'role="menuitem"' in html, "Intrarile meniului (role=menuitem) trebuie pastrate"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
256
tests/test_web_tema_culori.py
Normal file
256
tests/test_web_tema_culori.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Teste US-013 (PRD 5.10): Tema de culori ROMFAST (accent azur) + tipografie IBM Plex.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
|
||||
|
||||
Testeaza:
|
||||
- test_paleta_accent_azur_definita: accentul azur ROMFAST definit corect in :root si [data-theme="light"]
|
||||
- test_font_ibm_plex_aplicat: IBM Plex Sans + Mono declarate in font-family si @font-face
|
||||
- test_contrast_aa_pe_text_principal: contrast text principal >= 4.5:1 in dark si light
|
||||
"""
|
||||
|
||||
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, "culori.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 _get_style_block(html: str) -> str:
|
||||
"""Extrage continutul primului <style> din HTML."""
|
||||
m = re.search(r"<style>(.*?)</style>", html, re.DOTALL)
|
||||
assert m, "<style> negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _hex_to_srgb(hex_color: str) -> tuple[float, float, float]:
|
||||
"""Converteste hex (#rrggbb) la tuple (r, g, b) in [0,1]."""
|
||||
h = hex_color.lstrip("#")
|
||||
assert len(h) == 6, f"Hex invalid: {hex_color}"
|
||||
r = int(h[0:2], 16) / 255.0
|
||||
g = int(h[2:4], 16) / 255.0
|
||||
b = int(h[4:6], 16) / 255.0
|
||||
return r, g, b
|
||||
|
||||
|
||||
def _linearize(c: float) -> float:
|
||||
"""Liniarizeaza o componenta sRGB pentru calcul luminanta WCAG."""
|
||||
if c <= 0.04045:
|
||||
return c / 12.92
|
||||
return ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def _luminance(hex_color: str) -> float:
|
||||
"""Calculeaza luminanta relativa WCAG 2.1 pentru o culoare hex."""
|
||||
r, g, b = _hex_to_srgb(hex_color)
|
||||
rl = _linearize(r)
|
||||
gl = _linearize(g)
|
||||
bl = _linearize(b)
|
||||
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
|
||||
|
||||
|
||||
def _contrast_ratio(c1: str, c2: str) -> float:
|
||||
"""Calculeaza raportul de contrast WCAG 2.1 intre doua culori hex."""
|
||||
l1 = _luminance(c1)
|
||||
l2 = _luminance(c2)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
# ── test_paleta_accent_azur_definita ─────────────────────────────────────────
|
||||
|
||||
def test_paleta_accent_azur_definita(client):
|
||||
"""Accentul azur ROMFAST definit corect si neutrele actualizate conform DESIGN.md.
|
||||
|
||||
:root (dark default):
|
||||
--accent:#2E74D6
|
||||
--bg:#0f1218 --card:#181c24 --ink:#e6e9ef --muted:#8b93a7 --line:#262b36
|
||||
--ok:#2FBF8F --warn:#E0A93B --err:#E05D5D
|
||||
|
||||
[data-theme="light"]:
|
||||
--accent:#1F66C9
|
||||
--bg:#f5f7fa --card:#ffffff --ink:#1a1d24 --muted:#5c6473 --line:#e2e5ea
|
||||
--ok:#15803d --warn:#b45309 --err:#dc2626
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# Paleta dark (:root)
|
||||
dark_vars = {
|
||||
"--accent": "#2E74D6",
|
||||
"--bg": "#0f1218",
|
||||
"--card": "#181c24",
|
||||
"--ink": "#e6e9ef",
|
||||
"--muted": "#8b93a7",
|
||||
"--line": "#262b36",
|
||||
"--ok": "#2FBF8F",
|
||||
"--warn": "#E0A93B",
|
||||
"--err": "#E05D5D",
|
||||
}
|
||||
# Extrage blocul :root
|
||||
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert root_m, "Blocul :root negasit in <style>"
|
||||
root_block = root_m.group(1)
|
||||
|
||||
for var, val in dark_vars.items():
|
||||
assert val.lower() in root_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste sau are valoare gresita in :root (dark). "
|
||||
f"Continut :root: {root_block.strip()}"
|
||||
)
|
||||
|
||||
# Paleta light ([data-theme="light"])
|
||||
light_vars = {
|
||||
"--accent": "#1F66C9",
|
||||
"--bg": "#f5f7fa",
|
||||
"--card": "#ffffff",
|
||||
"--ink": "#1a1d24",
|
||||
"--muted": "#5c6473",
|
||||
"--line": "#e2e5ea",
|
||||
"--ok": "#15803d",
|
||||
"--warn": "#b45309",
|
||||
"--err": "#dc2626",
|
||||
}
|
||||
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
|
||||
assert light_m, 'Blocul [data-theme="light"] negasit in <style>'
|
||||
light_block = light_m.group(1)
|
||||
|
||||
for var, val in light_vars.items():
|
||||
assert val.lower() in light_block.lower(), (
|
||||
f"Variabila {var}:{val} lipseste sau are valoare gresita in [data-theme=\"light\"]. "
|
||||
f"Continut light: {light_block.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# ── test_font_ibm_plex_aplicat ────────────────────────────────────────────────
|
||||
|
||||
def test_font_ibm_plex_aplicat(client):
|
||||
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap.
|
||||
|
||||
Verifica:
|
||||
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans)
|
||||
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono'
|
||||
- @font-face include font-display:swap
|
||||
- @font-face pointeaza spre /static/fonts/
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# 1. body font-family contine IBM Plex Sans
|
||||
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert body_m, "Regula 'body { ... }' negasita in <style>"
|
||||
body_block = body_m.group(1)
|
||||
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), (
|
||||
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}"
|
||||
)
|
||||
|
||||
# 2. Exista cel putin un @font-face cu IBM Plex
|
||||
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert font_face_blocks, "@font-face negasit in <style>"
|
||||
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
|
||||
assert ibm_face, (
|
||||
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. "
|
||||
f"Blocuri @font-face gasite: {font_face_blocks}"
|
||||
)
|
||||
|
||||
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face
|
||||
swap_present = any("swap" in b.lower() for b in ibm_face)
|
||||
assert swap_present, (
|
||||
"font-display:swap lipseste din @font-face IBM Plex. "
|
||||
f"Blocuri @font-face IBM Plex: {ibm_face}"
|
||||
)
|
||||
|
||||
# 4. @font-face pointeaza spre /static/fonts/
|
||||
fonts_src = any("/static/fonts/" in b for b in ibm_face)
|
||||
assert fonts_src, (
|
||||
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
|
||||
f"Blocuri: {ibm_face}"
|
||||
)
|
||||
|
||||
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
|
||||
# (fie @font-face, fie o regula font-family cu monospace)
|
||||
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
|
||||
if not has_mono:
|
||||
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
|
||||
has_mono = "IBM Plex Mono" in style
|
||||
assert has_mono, (
|
||||
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
|
||||
)
|
||||
|
||||
|
||||
# ── test_contrast_aa_pe_text_principal ───────────────────────────────────────
|
||||
|
||||
def test_contrast_aa_pe_text_principal(client):
|
||||
"""Contrastul text principal este >= 4.5:1 in dark si light (WCAG 2.1 AA).
|
||||
|
||||
Dark: --ink:#e6e9ef pe --bg:#0f1218
|
||||
Light: --ink:#1a1d24 pe --bg:#f5f7fa
|
||||
Accent ca text pe alb: #1F66C9 pe #ffffff (WCAG AA pentru text normal)
|
||||
"""
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
style = _get_style_block(resp.text)
|
||||
|
||||
# Extrage valorile de culoare din :root si [data-theme="light"]
|
||||
def _extract_var(block: str, var_name: str) -> str | None:
|
||||
m = re.search(
|
||||
re.escape(var_name) + r"\s*:\s*(#[0-9a-fA-F]{6})",
|
||||
block,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return m.group(1) if m else None
|
||||
|
||||
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
|
||||
assert root_m, "Blocul :root negasit"
|
||||
root_block = root_m.group(1)
|
||||
|
||||
light_m = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', style, re.DOTALL)
|
||||
assert light_m, 'Blocul [data-theme="light"] negasit'
|
||||
light_block = light_m.group(1)
|
||||
|
||||
# --- Dark: ink pe bg ---
|
||||
dark_ink = _extract_var(root_block, "--ink")
|
||||
dark_bg = _extract_var(root_block, "--bg")
|
||||
assert dark_ink and dark_bg, (
|
||||
f"Nu am putut extrage --ink/{dark_ink} sau --bg/{dark_bg} din :root"
|
||||
)
|
||||
cr_dark = _contrast_ratio(dark_ink, dark_bg)
|
||||
assert cr_dark >= 4.5, (
|
||||
f"Contrast dark insuficient: {dark_ink} pe {dark_bg} = {cr_dark:.2f}:1 (minim 4.5:1 AA)"
|
||||
)
|
||||
|
||||
# --- Light: ink pe bg ---
|
||||
light_ink = _extract_var(light_block, "--ink")
|
||||
light_bg = _extract_var(light_block, "--bg")
|
||||
assert light_ink and light_bg, (
|
||||
f"Nu am putut extrage --ink/{light_ink} sau --bg/{light_bg} din [data-theme=light]"
|
||||
)
|
||||
cr_light = _contrast_ratio(light_ink, light_bg)
|
||||
assert cr_light >= 4.5, (
|
||||
f"Contrast light insuficient: {light_ink} pe {light_bg} = {cr_light:.2f}:1 (minim 4.5:1 AA)"
|
||||
)
|
||||
|
||||
# --- Accent ca text pe alb (tema light) ---
|
||||
light_accent = _extract_var(light_block, "--accent")
|
||||
assert light_accent, f"--accent negasit in [data-theme=light]: {light_block.strip()}"
|
||||
cr_accent_white = _contrast_ratio(light_accent, "#ffffff")
|
||||
assert cr_accent_white >= 4.5, (
|
||||
f"Accent light ({light_accent}) pe alb: contrast {cr_accent_white:.2f}:1 < 4.5:1 AA. "
|
||||
f"Foloseste o varianta mai inchisa (ex. #1F66C9)."
|
||||
)
|
||||
@@ -153,9 +153,8 @@ def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
|
||||
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
|
||||
# US-010: sectiunea de ajutor (<details class="ajutor-mapari">) eliminata
|
||||
assert "ajutor-mapari" not in html
|
||||
# antet de coloana compact
|
||||
assert ">In coada<" in html
|
||||
# proza inline veche eliminata de pe sectiuni
|
||||
|
||||
Reference in New Issue
Block a user