feat(web): dashboard compact — import pe Acasa, status cu bife, Trimiteri lizibile, Mapari complete (3.5)
Acasa = ecran de import (tab Import scos, ?tab=import->Acasa). Bara status compacta pe 2 randuri cu bife accesibile (glife + text) + data formatata. 'Coada'->'Trimiteri': coloane RO, stare umana, detaliu la click in panou dedicat. Mapari pe 3 sectiuni (de rezolvat / op salvate / formate coloane), Cont doar cheie+creds. Filtrare Trimiteri, corectie inline needs_data cu re-enqueue + detectie coliziune idempotency, badge contoare pe tab-uri. Helper pur partajat payload_view.py (web + GET /v1/prezentari). Backend trimitere (worker/idempotenta/mapping/schema) neatins. 483 teste. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ from ...mapping import (
|
||||
save_mapping,
|
||||
)
|
||||
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||
from ...payload_view import prezentare_din_payload
|
||||
from ...validation import validate_prezentare
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
@@ -139,19 +140,31 @@ def list_prezentari(
|
||||
conn = get_connection()
|
||||
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.
|
||||
cols = (
|
||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||
"created_at, updated_at, payload_json"
|
||||
)
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
|
||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? "
|
||||
f"ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [status, limit],
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||
f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [limit],
|
||||
).fetchall()
|
||||
return {"submissions": [dict(r) for r in rows]}
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
# Campuri afisabile derivate din payload (acelasi helper ca dashboardul web);
|
||||
# payload_json brut nu se intoarce in raspuns.
|
||||
d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None))
|
||||
out.append(d)
|
||||
return {"submissions": out}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
112
app/payload_view.py
Normal file
112
app/payload_view.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5).
|
||||
|
||||
Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API
|
||||
(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie
|
||||
eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON
|
||||
plaintext, vezi `submissions.payload_json`) sau un dict deja parsat.
|
||||
|
||||
Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza
|
||||
la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor:
|
||||
`nr_inmatriculare` vs `numar`/`numarInmatriculare`; coercion Excel pe odometru/VIN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
EMPTY = "—"
|
||||
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
"""str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe)."""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _clean_odometru(value: Any) -> str:
|
||||
"""Odometru afisat curat: strip '.0' din coercion Excel (123456.0 -> 123456)."""
|
||||
s = _clean_str(value)
|
||||
if "." in s:
|
||||
before, after = s.split(".", 1)
|
||||
if after == "0" and before.lstrip("-").isdigit():
|
||||
return before
|
||||
return s
|
||||
|
||||
|
||||
def _vin_scurt(vin: str) -> str:
|
||||
"""Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu).
|
||||
|
||||
VIN are 17 caractere; in tabel aratam ultimele 6 cu prefix elipsa ca sa
|
||||
incapa fara sa ascundem complet identitatea vehiculului.
|
||||
"""
|
||||
if not vin:
|
||||
return ""
|
||||
if len(vin) <= 8:
|
||||
return vin
|
||||
return "…" + vin[-6:]
|
||||
|
||||
|
||||
def _prima_prestatie(prestatii: Any) -> dict[str, Any]:
|
||||
"""Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {})."""
|
||||
if isinstance(prestatii, list):
|
||||
for item in prestatii:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return {}
|
||||
|
||||
|
||||
def _payload_dict(payload: str | dict | None) -> dict[str, Any]:
|
||||
"""Normalizeaza intrarea la dict. JSON invalid / tip neasteptat -> {}."""
|
||||
if payload is None:
|
||||
return {}
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
"""Campuri afisabile dintr-un payload de submission.
|
||||
|
||||
Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc):
|
||||
vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod.
|
||||
|
||||
`operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR
|
||||
(`cod_prestatie`) sau, in lipsa, codul intern (`cod_op_service`).
|
||||
"""
|
||||
data = _payload_dict(payload)
|
||||
|
||||
vin = _clean_str(data.get("vin"))
|
||||
nr = _clean_str(
|
||||
data.get("nr_inmatriculare")
|
||||
or data.get("numar")
|
||||
or data.get("numarInmatriculare")
|
||||
)
|
||||
odo = _clean_odometru(
|
||||
data.get("odometru_final")
|
||||
if data.get("odometru_final") is not None
|
||||
else data.get("odometru")
|
||||
)
|
||||
data_prest = _clean_str(data.get("data_prestatie") or data.get("dataPrestatie"))
|
||||
|
||||
item = _prima_prestatie(data.get("prestatii"))
|
||||
cod = _clean_str(item.get("cod_prestatie") or item.get("cod_op_service"))
|
||||
denumire = _clean_str(item.get("denumire"))
|
||||
operatie = denumire or cod
|
||||
|
||||
return {
|
||||
"vehicul_nr": nr or EMPTY,
|
||||
"vin": vin or EMPTY,
|
||||
"vin_scurt": _vin_scurt(vin) or EMPTY,
|
||||
"operatie": operatie or EMPTY,
|
||||
"data_prestatie": data_prest or EMPTY,
|
||||
"odometru": odo or EMPTY,
|
||||
"cod": cod or EMPTY,
|
||||
}
|
||||
@@ -6,6 +6,8 @@ Functii pure: fara DB, fara request. Usor de testat unitar si de importat in tem
|
||||
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -119,6 +121,80 @@ def eticheta_rar(stare: str) -> Eticheta:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format data RAR (US-001, PRD 3.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_data_rar(raw: object) -> str:
|
||||
"""Formateaza un timestamp ISO ca `dd.mm.yyyy hh24:mi:ss` (ora romaneasca).
|
||||
|
||||
- Valoare lipsa (None / "") -> "—".
|
||||
- ISO valid (cu sau fara timezone / 'Z' / microsecunde) -> data formatata,
|
||||
fara fractiuni de secunda.
|
||||
- Format invalid -> fallback grijuliu: intoarce stringul brut (nu arunca),
|
||||
ca operatorul sa vada totusi ceva, nu o pagina rupta.
|
||||
"""
|
||||
if raw is None:
|
||||
return "—"
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return "—"
|
||||
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except (ValueError, TypeError):
|
||||
return s
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Motiv uman din rar_error (US-004, PRD 3.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def motiv_uman(status: str, rar_error: object) -> str:
|
||||
"""Transforma `rar_error` (JSON tehnic) intr-un motiv lizibil pentru coloana Motiv.
|
||||
|
||||
Formele intalnite (vezi router.py / mapping.py):
|
||||
- validare continut: list[{field, message}] -> mesajele concatenate.
|
||||
- operatie nemapata: {"unmapped": [{cod_op_service, denumire}]}.
|
||||
- auto-send oprit: {"auto_send": "..."}.
|
||||
- eroare RAR: text simplu sau dict generic.
|
||||
Fara rar_error -> "". Nu arunca niciodata (degradeaza la text brut trunchiat).
|
||||
"""
|
||||
if not rar_error:
|
||||
return ""
|
||||
raw = rar_error if isinstance(rar_error, str) else str(rar_error)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return raw[:160]
|
||||
|
||||
if isinstance(data, dict):
|
||||
if "unmapped" in data:
|
||||
ops = data.get("unmapped") or []
|
||||
nume = ", ".join(
|
||||
(o.get("cod_op_service") or "") for o in ops if isinstance(o, dict)
|
||||
).strip(", ")
|
||||
return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa"
|
||||
if "auto_send" in data:
|
||||
return "Necesita confirmare manuala (auto-send oprit pentru cod)"
|
||||
parti = [f"{k}: {v}" for k, v in data.items()]
|
||||
return "; ".join(parti)[:200]
|
||||
|
||||
if isinstance(data, list):
|
||||
msgs: list[str] = []
|
||||
for e in data:
|
||||
if isinstance(e, dict):
|
||||
msgs.append(
|
||||
str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values()))
|
||||
)
|
||||
else:
|
||||
msgs.append(str(e))
|
||||
return "; ".join(m for m in msgs if m)[:200]
|
||||
|
||||
return str(data)[:160]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constante auxiliare (microcopy fix, fara logica)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,22 +13,26 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import rotate_api_key
|
||||
from ..payload_view import prezentare_din_payload
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from .labels import (
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
eticheta_rar,
|
||||
eticheta_stare,
|
||||
eticheta_worker,
|
||||
format_data_rar,
|
||||
motiv_uman,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
@@ -43,11 +47,14 @@ from ..config import get_settings
|
||||
from ..crypto import decrypt_creds, encrypt_creds
|
||||
from ..db import get_connection, read_heartbeat
|
||||
from ..idempotency import build_key, canonicalize_row
|
||||
from ..validation import validate_prezentare
|
||||
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
|
||||
from ..users import is_account_admin
|
||||
from ..mapping import (
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
has_no_auto_send,
|
||||
load_mapping_meta,
|
||||
load_nomenclator,
|
||||
pending_unmapped,
|
||||
@@ -121,7 +128,9 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
return "indisponibil?" if age > 108000 else "ok"
|
||||
|
||||
|
||||
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
|
||||
# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import
|
||||
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
|
||||
_TABS_VALIDE = {"acasa", "coada", "mapari", "cont", "nomenclator"}
|
||||
|
||||
|
||||
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
@@ -158,13 +167,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
"are_creds": are_creds,
|
||||
"are_trimiteri": are_trimiteri,
|
||||
"are_cheie_folosita": are_cheie_folosita,
|
||||
# US-002: Acasa include caseta de upload -> are nevoie de csrf_token
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
|
||||
|
||||
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
|
||||
"""Randeaza panoul Acasa ca string HTML."""
|
||||
if conn is None:
|
||||
return templates.get_template("_acasa.html").render({"request": request})
|
||||
return templates.get_template("_acasa.html").render(
|
||||
{"request": request, "csrf_token": get_csrf_token(request)}
|
||||
)
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.get_template("_acasa.html").render(ctx)
|
||||
|
||||
@@ -183,10 +196,12 @@ def _render_panel_coada(request: Request) -> str:
|
||||
|
||||
|
||||
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Mapari ca string HTML."""
|
||||
"""Randeaza panoul Mapari ca string HTML (3 sectiuni: de rezolvat / op salvate / formate)."""
|
||||
return templates.get_template("_mapari.html").render({
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"saved_mappings": _load_saved_op_mappings(conn, account_id),
|
||||
"column_formats": _load_column_formats(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
@@ -251,12 +266,19 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
conn = get_connection()
|
||||
try:
|
||||
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
|
||||
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari, blocate -> Trimiteri.
|
||||
counts = _status_counts(conn, account_id)
|
||||
badges = {
|
||||
"mapari": counts.get("needs_mapping", 0),
|
||||
"coada": sum(counts.get(s, 0) for s in _BLOCKED),
|
||||
}
|
||||
ctx = {
|
||||
"request": request,
|
||||
"rar_env": get_settings().rar_env,
|
||||
"version": __version__,
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"badges": badges,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
@@ -355,16 +377,22 @@ def fragment_status(request: Request) -> HTMLResponse:
|
||||
# Etichete umane pre-calculate (nu logica in template)
|
||||
worker_lbl = eticheta_worker(worker_alive)
|
||||
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
|
||||
rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
|
||||
rar_ok = rar_state == "ok"
|
||||
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
|
||||
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
|
||||
return templates.TemplateResponse("_status.html", {
|
||||
"request": request,
|
||||
"worker_lbl": worker_lbl,
|
||||
"rar_lbl": rar_lbl,
|
||||
# Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare
|
||||
"worker_ok": worker_alive,
|
||||
"rar_ok": rar_ok,
|
||||
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
||||
"counts_queued": counts.get("queued", 0),
|
||||
"counts_sent": counts.get("sent", 0),
|
||||
"blocate_total": blocate_total,
|
||||
"blocate_defalcat": _blocate_defalcat(counts),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
@@ -372,23 +400,376 @@ def fragment_status(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _is_iso_date(value: object) -> bool:
|
||||
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
|
||||
s = str(value or "").strip()
|
||||
if len(s) != 10:
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(s, "%Y-%m-%d")
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _submission_row_view(r) -> dict:
|
||||
"""Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004)."""
|
||||
eticheta = eticheta_stare(r["status"])
|
||||
return {
|
||||
"id": r["id"],
|
||||
"status": r["status"],
|
||||
"stare_text": eticheta[0],
|
||||
"stare_css": eticheta[2],
|
||||
"prez": prezentare_din_payload(r["payload_json"]),
|
||||
"id_prezentare": r["id_prezentare"],
|
||||
"updated_at": format_data_rar(r["updated_at"]),
|
||||
"motiv": motiv_uman(r["status"], r["rar_error"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
def fragment_submissions(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
vehicul: str | None = None,
|
||||
data_de: str | None = None,
|
||||
data_pana: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
|
||||
|
||||
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
|
||||
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
|
||||
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
status = (status or "").strip() or None
|
||||
vehicul_q = (vehicul or "").strip().upper() or None
|
||||
data_de = (data_de or "").strip() or None
|
||||
data_pana = (data_pana or "").strip() or None
|
||||
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
where = [scope_sql]
|
||||
params: list = list(scope_params)
|
||||
if status:
|
||||
where.append("status=?")
|
||||
params.append(status)
|
||||
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
|
||||
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
|
||||
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
|
||||
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
|
||||
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||
"updated_at, payload_json FROM submissions "
|
||||
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
view = []
|
||||
for r in rows:
|
||||
v = _submission_row_view(r)
|
||||
prez = v["prez"]
|
||||
if vehicul_q:
|
||||
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
|
||||
if vehicul_q not in hay:
|
||||
continue
|
||||
if data_de or data_pana:
|
||||
d = prez["data_prestatie"]
|
||||
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
|
||||
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
|
||||
if not _is_iso_date(d):
|
||||
continue
|
||||
if data_de and d < data_de:
|
||||
continue
|
||||
if data_pana and d > data_pana:
|
||||
continue
|
||||
view.append(v)
|
||||
if len(view) >= 200:
|
||||
break
|
||||
|
||||
return templates.TemplateResponse("_submissions.html", {
|
||||
"request": request,
|
||||
"rows": view,
|
||||
"filtru_activ": filtru_activ,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
|
||||
_CORECTABILE = ("needs_data", "needs_mapping")
|
||||
|
||||
|
||||
def _payload_form_values(payload_json) -> dict:
|
||||
"""Valori brute pentru prefill-ul formularului de corectie (US-010)."""
|
||||
try:
|
||||
data = json.loads(payload_json) if payload_json else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (ValueError, TypeError):
|
||||
data = {}
|
||||
return {
|
||||
"form_vin": data.get("vin") or "",
|
||||
"form_nr": data.get("nr_inmatriculare") or "",
|
||||
"form_data": data.get("data_prestatie") or "",
|
||||
"form_odo_final": data.get("odometru_final") or "",
|
||||
"form_odo_initial": data.get("odometru_initial") or "",
|
||||
}
|
||||
|
||||
|
||||
def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
error: bool = False, corectie_errors: list | None = None) -> dict:
|
||||
"""Context pentru _trimitere_detaliu.html dintr-un rand de submission."""
|
||||
eticheta = eticheta_stare(row["status"])
|
||||
ctx = {
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"id": row["id"],
|
||||
"status": row["status"],
|
||||
"stare_text": eticheta[0],
|
||||
"stare_css": eticheta[2],
|
||||
"stare_subtext": eticheta[1],
|
||||
"prez": prezentare_din_payload(row["payload_json"]),
|
||||
"id_prezentare": row["id_prezentare"],
|
||||
"rar_status_code": row["rar_status_code"],
|
||||
"rar_error": row["rar_error"],
|
||||
"motiv": motiv_uman(row["status"], row["rar_error"]),
|
||||
"retry_count": row["retry_count"],
|
||||
"created_at": format_data_rar(row["created_at"]),
|
||||
"updated_at": format_data_rar(row["updated_at"]),
|
||||
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
|
||||
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
|
||||
"editabil": row["status"] in _CORECTABILE,
|
||||
"corectie_msg": message,
|
||||
"corectie_error": error,
|
||||
"corectie_errors": corectie_errors or [],
|
||||
}
|
||||
ctx.update(_payload_form_values(row["payload_json"]))
|
||||
return ctx
|
||||
|
||||
|
||||
def _fetch_submission_scoped(conn, account_id: int, submission_id: int):
|
||||
"""Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3)."""
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
|
||||
|
||||
# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e
|
||||
# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload).
|
||||
@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse)
|
||||
def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse:
|
||||
"""Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu).
|
||||
|
||||
Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont
|
||||
(acelasi mesaj, nu confirmam existenta — vezi B3/router.py).
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
|
||||
"FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"ORDER BY id DESC LIMIT 100",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
|
||||
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
||||
return templates.TemplateResponse("_trimitere_detaliu.html", _detaliu_ctx(request, row))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/trimitere/{submission_id}/corecteaza", response_class=HTMLResponse)
|
||||
async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
||||
"""Corectie inline pentru randuri ne-trimise blocate (needs_data/needs_mapping).
|
||||
|
||||
Re-valideaza (validation.py, fara reguli noi), reconstruieste payload_json,
|
||||
recalculeaza idempotency_key (canonicalize -> build_key, ca la enqueue) si
|
||||
re-pune randul in 'queued' (re-enqueue). NU atinge worker-ul / masina de stari.
|
||||
Randurile sent/sending/queued/error raman read-only (gard explicit -> 403).
|
||||
Coliziune de idempotency detectata INAINTE de UPDATE (fara 500/duplicat).
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
form = await request.form()
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
||||
# Gard read-only: doar randurile blocate ne-trimise sunt corectabile.
|
||||
if row["status"] not in _CORECTABILE:
|
||||
raise HTTPException(status_code=403, detail="trimitere read-only (deja procesata)")
|
||||
|
||||
try:
|
||||
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
||||
if not isinstance(content, dict):
|
||||
content = {}
|
||||
except (ValueError, TypeError):
|
||||
content = {}
|
||||
|
||||
# Aplica DOAR campurile prezente in form (negoale).
|
||||
for camp in ("vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "odometru_initial"):
|
||||
val = form.get(camp)
|
||||
if isinstance(val, str) and val.strip() != "":
|
||||
content[camp] = val.strip()
|
||||
|
||||
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
|
||||
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
|
||||
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
|
||||
mapping_meta = load_mapping_meta(conn, account_id)
|
||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
|
||||
content["prestatii"] = resolved
|
||||
|
||||
# Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie.
|
||||
canon = canonicalize_row(content)
|
||||
content.update({
|
||||
"vin": canon["vin"],
|
||||
"nr_inmatriculare": canon["nr_inmatriculare"],
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
|
||||
if unmapped:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, error=True,
|
||||
message="Lipseste inca un cod RAR — rezolva operatia in tab-ul Mapari."),
|
||||
)
|
||||
|
||||
if has_no_auto_send(resolved, mapping_meta):
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json,
|
||||
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
|
||||
ensure_ascii=False),
|
||||
row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, error=True,
|
||||
message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."),
|
||||
)
|
||||
|
||||
errors = validate_prezentare(content)
|
||||
if errors:
|
||||
# Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp.
|
||||
conn.execute(
|
||||
"UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(payload_json, json.dumps(errors, ensure_ascii=False), row["id"]),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, message="Mai sunt campuri invalide — vezi mai jos.",
|
||||
error=True, corectie_errors=errors),
|
||||
)
|
||||
|
||||
# Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat.
|
||||
new_key = build_key(account_id, canon)
|
||||
if new_key != row["idempotency_key"]:
|
||||
dup = conn.execute(
|
||||
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
||||
(new_key, row["id"]),
|
||||
).fetchone()
|
||||
if dup:
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row2,
|
||||
message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.",
|
||||
error=True,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||
"updated_at=datetime('now') WHERE id=?",
|
||||
(new_key, payload_json, row["id"]),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
||||
# pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500.
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, error=True,
|
||||
message="Exista deja o trimitere identica. Corectia a fost oprita."),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
|
||||
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
|
||||
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie "
|
||||
"FROM operations_mapping o "
|
||||
"LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie "
|
||||
"WHERE o.account_id=? ORDER BY o.cod_op_service",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"cod_op_service": r["cod_op_service"],
|
||||
"cod_prestatie": r["cod_prestatie"],
|
||||
"auto_send": bool(r["auto_send"]),
|
||||
"nume_prestatie": r["nume_prestatie"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _load_column_formats(conn, account_id: int) -> list[dict]:
|
||||
"""Formate de coloane salvate (column_mappings) ale contului (US-006).
|
||||
|
||||
Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT id, signature_coloane, json_mapare, format_data, created_at "
|
||||
"FROM column_mappings WHERE account_id=? ORDER BY id DESC",
|
||||
(acct,),
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
jm = json.loads(r["json_mapare"]) if r["json_mapare"] else {}
|
||||
except (ValueError, TypeError):
|
||||
jm = {}
|
||||
out.append({
|
||||
"id": r["id"],
|
||||
"signature_coloane": r["signature_coloane"],
|
||||
"mappings": jm,
|
||||
"columns": list(jm.keys()),
|
||||
"format_data": r["format_data"],
|
||||
"created_at": r["created_at"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _render_mapari(
|
||||
request: Request, conn, account_id: int, *, message: str | None = None
|
||||
) -> HTMLResponse:
|
||||
@@ -397,6 +778,8 @@ def _render_mapari(
|
||||
{
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"saved_mappings": _load_saved_op_mappings(conn, account_id),
|
||||
"column_formats": _load_column_formats(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": message,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
@@ -447,6 +830,146 @@ def post_mapare(
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere #
|
||||
# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. #
|
||||
# =========================================================================== #
|
||||
|
||||
@router.post("/mapari/salvate", response_class=HTMLResponse)
|
||||
def post_editeaza_mapare_salvata(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
cod_prestatie: str = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
auto_send: bool = Form(False),
|
||||
) -> HTMLResponse:
|
||||
"""Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele.
|
||||
|
||||
Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) —
|
||||
cross-account imposibil). Respinge cod inexistent in nomenclator.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
||||
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. "
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/mapari/salvate/sterge", response_class=HTMLResponse)
|
||||
def post_sterge_mapare_salvata(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Sterge o mapare op->cod salvata. Scoped pe contul sesiunii."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
|
||||
(acct, cod_op_service.strip()),
|
||||
)
|
||||
return _render_mapari(
|
||||
request, conn, account_id,
|
||||
message=f"Mapare stearsa: {cod_op_service.strip()}.",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# US-006 — Formate de coloane salvate: editare format data + stergere #
|
||||
# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). #
|
||||
# =========================================================================== #
|
||||
|
||||
@router.post("/formate-coloane/editeaza", response_class=HTMLResponse)
|
||||
async def post_editeaza_format_coloane(
|
||||
request: Request,
|
||||
format_id: int = Form(...),
|
||||
format_data: str | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id.
|
||||
|
||||
json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
form = await request.form()
|
||||
json_mapare_raw = form.get("json_mapare")
|
||||
conn = get_connection()
|
||||
try:
|
||||
owned = conn.execute(
|
||||
"SELECT 1 FROM column_mappings WHERE id=? AND account_id=?",
|
||||
(format_id, acct),
|
||||
).fetchone()
|
||||
if not owned:
|
||||
return _render_mapari(
|
||||
request, conn, account_id, message="Format inexistent sau inaccesibil."
|
||||
)
|
||||
fmt = (format_data or "").strip() or None
|
||||
if isinstance(json_mapare_raw, str) and json_mapare_raw.strip():
|
||||
try:
|
||||
jm = json.loads(json_mapare_raw)
|
||||
if not isinstance(jm, dict):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return _render_mapari(
|
||||
request, conn, account_id, message="Mapare coloane invalida (JSON)."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?",
|
||||
(json.dumps(jm, ensure_ascii=False), fmt, format_id, acct),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?",
|
||||
(fmt, format_id, acct),
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/formate-coloane/sterge", response_class=HTMLResponse)
|
||||
def post_sterge_format_coloane(
|
||||
request: Request,
|
||||
format_id: int = Form(...),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Sterge un format de coloane salvat. Scoped pe cont prin id+account_id."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM column_mappings WHERE id=? AND account_id=?",
|
||||
(format_id, acct),
|
||||
)
|
||||
return _render_mapari(request, conn, account_id, message="Format de coloane sters.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# Import UI (U5) — upload → mapare coloane → preview → confirmare #
|
||||
# Consuma helper-e din import_router fara a edita fisierul backend. #
|
||||
|
||||
@@ -1,81 +1,56 @@
|
||||
<div id="acasa-section">
|
||||
|
||||
{% set toti_esentiali = are_creds and are_trimiteri %}
|
||||
{# === Centru de greutate: caseta de upload (importul e operatia principala) === #}
|
||||
{% include '_upload.html' %}
|
||||
|
||||
{% if toti_esentiali %}
|
||||
{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #}
|
||||
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
|
||||
Totul e configurat —
|
||||
<a href="/?tab=coada">vezi coada</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
|
||||
{# === Subordonat: primii pasi pe un singur rand compact === #}
|
||||
{% set toti_esentiali = are_creds and are_trimiteri %}
|
||||
{% if not toti_esentiali %}
|
||||
<div class="card" style="margin-top:14px; padding:12px 16px;">
|
||||
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
|
||||
<span class="muted" style="font-weight:600;">Primii pasi:</span>
|
||||
|
||||
{# Pas 1: Conecteaza contul RAR (esential) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
{# Pas 1: Cont RAR (esential) #}
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_creds %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
<span class="muted" aria-hidden="true">○</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
Email + parola portal AUTOPASS RAR
|
||||
<a href="/?tab=cont">Cont RAR</a>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{# Pas 2: Cheie API (optional) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_cheie_folosita %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
<span class="muted" aria-hidden="true">○</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=cont">Ia-ti cheia API</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
<em>Optional</em> — doar daca trimiti din soft propriu prin API
|
||||
<a href="/?tab=cont">Cheie API</a>
|
||||
<span class="muted">(optional)</span>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{# Pas 3: Import primul fisier (esential) #}
|
||||
<li style="display:flex; align-items:flex-start; gap:10px;">
|
||||
{# Pas 3: Import (esential) — marcat ca pas curent #}
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
{% if are_trimiteri %}
|
||||
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">✓</span>
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</span>
|
||||
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">●</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="/?tab=import">Importa primul fisier</a>
|
||||
<span class="muted" style="font-size:12px; display:block;">
|
||||
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
|
||||
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
|
||||
<p class="muted" style="margin:0 0 10px; font-size:13px;">
|
||||
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
|
||||
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
|
||||
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
|
||||
</p>
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
|
||||
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
|
||||
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
|
||||
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Subordonat: ajutor rapid pe un rand discret === #}
|
||||
<div style="margin-top:10px; font-size:13px; color:var(--muted);
|
||||
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
|
||||
<span>Ajutor:</span>
|
||||
<a href="/?tab=coada">Trimiteri</a>
|
||||
<a href="/?tab=mapari">Mapari</a>
|
||||
<a href="/?tab=nomenclator">Coduri RAR</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
<div id="coada-section">
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
|
||||
<h2 style="font-size:15px; margin:0;">Trimiteri catre RAR</h2>
|
||||
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
|
||||
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
|
||||
</span>
|
||||
</div>
|
||||
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
|
||||
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include -->
|
||||
<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>
|
||||
<select id="f-status" name="status">
|
||||
<option value="">toate</option>
|
||||
<option value="queued">in asteptare</option>
|
||||
<option value="sent">declarate la RAR</option>
|
||||
<option value="needs_mapping">lipsa cod</option>
|
||||
<option value="needs_data">date incomplete</option>
|
||||
<option value="error">eroare</option>
|
||||
<option value="sending">se trimite</option>
|
||||
</select>
|
||||
</div>
|
||||
<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;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
|
||||
<input id="f-data-de" type="date" name="data_de">
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
|
||||
<input id="f-data-pana" type="date" name="data_pana">
|
||||
</div>
|
||||
<button type="submit">Filtreaza</button>
|
||||
</form>
|
||||
|
||||
<div id="submissions-wrap"
|
||||
hx-get="/_fragments/submissions" hx-trigger="load, every 10s"
|
||||
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panou dedicat pentru detaliul trimiterii (NU inline in tabel: poll-ul de 10s
|
||||
din tabel ar sterge un expand inline). Gol pana la click pe un rand. -->
|
||||
<div id="trimitere-detaliu"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<div id="mapari-section" class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Mapari de rezolvat</h2>
|
||||
<div id="mapari-section">
|
||||
|
||||
{% if message %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
|
||||
|
||||
{% if not pending %}
|
||||
<div class="empty">
|
||||
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
|
||||
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
@@ -58,4 +63,120 @@
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 2: Mapari operatii salvate (operations_mapping) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Mapari operatii salvate</h2>
|
||||
|
||||
{% if not saved_mappings %}
|
||||
<div class="empty">
|
||||
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza;
|
||||
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</p>
|
||||
|
||||
{% for m in saved_mappings %}
|
||||
<form class="maprow" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
|
||||
|
||||
<div class="mapcol grow">
|
||||
<div><strong>{{ m.cod_op_service }}</strong></div>
|
||||
<div class="muted" style="font-size:12px;">
|
||||
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapcol">
|
||||
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ m.cod_op_service }}">
|
||||
{% for n in nomenclator %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mapcol">
|
||||
<label class="chk"><input type="checkbox" name="auto_send" value="true"
|
||||
{% if m.auto_send %}checked{% endif %}> auto-send</label>
|
||||
</div>
|
||||
|
||||
<div class="mapcol" style="display:flex; gap:6px;">
|
||||
<button type="submit">Salveaza</button>
|
||||
</div>
|
||||
<div class="mapcol">
|
||||
<button type="submit"
|
||||
hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?"
|
||||
style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</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 (nu suprascrie).
|
||||
</p>
|
||||
|
||||
{% for f in column_formats %}
|
||||
<div class="maprow" style="align-items:flex-start;">
|
||||
<div class="mapcol grow">
|
||||
<div style="font-size:13px; margin-bottom:4px;">
|
||||
<strong>{{ f.columns | length }} coloane recunoscute</strong>
|
||||
{% if f.format_data %}
|
||||
<span class="pill" title="format data">data: {{ f.format_data }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="muted" style="font-size:12px;">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mapcol" style="display:flex; gap:6px; align-items:center;"
|
||||
hx-post="/formate-coloane/editeaza" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<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>
|
||||
|
||||
<form class="mapcol"
|
||||
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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -156,12 +156,15 @@
|
||||
{% elif row.flags %}
|
||||
{{ row.flags[0] }}
|
||||
{% elif row.errors %}
|
||||
{%- set e = row.errors[0] -%}
|
||||
{# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #}
|
||||
{%- for e in row.errors -%}
|
||||
{%- if e is mapping -%}
|
||||
{{ e.values() | list | first }}
|
||||
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
|
||||
{%- else -%}
|
||||
{{ e }}
|
||||
{%- endif -%}
|
||||
{%- if not loop.last %}; {% endif -%}
|
||||
{%- endfor -%}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
@@ -242,7 +245,8 @@
|
||||
</form>
|
||||
|
||||
<div style="padding:8px 0 4px;">
|
||||
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
|
||||
<a href="#" class="muted" style="font-size:13px;"
|
||||
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -13,62 +13,52 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
|
||||
<!-- Rand 1: doua bife binare + ultima autentificare -->
|
||||
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
|
||||
|
||||
<!-- Starea worker (Trimitere automata) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
|
||||
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
|
||||
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
|
||||
</div>
|
||||
{% if worker_lbl[1] %}
|
||||
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
|
||||
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #}
|
||||
{% macro bifa(ok, text, tip) %}
|
||||
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;">
|
||||
{% if ok %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
<span class="s-sent">{{ text }}</span>
|
||||
{% else %}
|
||||
<span class="s-error" aria-hidden="true" style="font-weight:bold;">✗</span>
|
||||
<span class="s-error">{{ text }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
|
||||
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
|
||||
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<span class="muted">{{ eticheta_ultima_auth }}:</span>
|
||||
<span>{{ last_login }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Legatura RAR -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">Legatura RAR</div>
|
||||
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
|
||||
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
|
||||
</div>
|
||||
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
|
||||
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ultima autentificare RAR -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
|
||||
<div>{{ last_login or '—' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- In asteptare (queued) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">In asteptare</div>
|
||||
<div class="s-queued">{{ counts_queued }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Declarate la RAR (sent) -->
|
||||
<div>
|
||||
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
|
||||
<div class="s-sent">{{ counts_sent }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Rand 2: contoare coada -->
|
||||
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;">
|
||||
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span>
|
||||
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span>
|
||||
<span><span class="muted">Blocate:</span>
|
||||
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Defalcare blocate pe motiv (doar daca exista) -->
|
||||
{% if blocate_defalcat %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap;">
|
||||
{% for eticheta, n in blocate_defalcat %}
|
||||
{% if n > 0 %}
|
||||
<div>
|
||||
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
|
||||
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
|
||||
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
|
||||
{% if eticheta[1] %}
|
||||
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
|
||||
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
{% if rows %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Stare</th><th>idPrezentare</th><th>HTTP RAR</th><th>Retry</th><th>Actualizat</th><th>Motiv</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th>#</th>
|
||||
<th>Stare</th>
|
||||
<th>Vehicul</th>
|
||||
<th>Operatie</th>
|
||||
<th>Data prestatie</th>
|
||||
<th>Nr. prezentare RAR</th>
|
||||
<th>Actualizat</th>
|
||||
<th>Motiv</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.id }}</td>
|
||||
<td><span class="pill s-{{ r.status }}">{{ r.status }}</span></td>
|
||||
<tr id="trimitere-row-{{ r.id }}"
|
||||
hx-get="/_fragments/trimitere/{{ r.id }}"
|
||||
hx-target="#trimitere-detaliu"
|
||||
hx-swap="innerHTML"
|
||||
style="cursor:pointer;"
|
||||
title="Click pentru detaliul complet">
|
||||
<td class="muted">{{ r.id }}</td>
|
||||
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
|
||||
<td>
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ r.prez.operatie }}</td>
|
||||
<td>{{ r.prez.data_prestatie }}</td>
|
||||
<td>{{ r.id_prezentare or '—' }}</td>
|
||||
<td>{{ r.rar_status_code or '—' }}</td>
|
||||
<td>{{ r.retry_count }}</td>
|
||||
<td class="muted">{{ r.updated_at }}</td>
|
||||
<td class="muted">{{ (r.rar_error or '')[:80] }}</td>
|
||||
<td class="muted" style="white-space:normal; max-width:280px;">{{ r.motiv }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif filtru_activ %}
|
||||
<div class="empty">
|
||||
Nimic pe filtrul curent.
|
||||
<a href="#"
|
||||
onclick="var f=document.getElementById('filtre-trimiteri'); if(f) f.reset(); return true;"
|
||||
hx-get="/_fragments/submissions" hx-target="#submissions-wrap" hx-swap="innerHTML">
|
||||
sterge filtrele
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
Nicio trimitere inca —
|
||||
<a href="/?tab=import">incepe cu Import</a>
|
||||
<a href="/?tab=acasa">incepe cu un import</a>
|
||||
sau trimite o prezentare prin API.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
98
app/web/templates/_trimitere_detaliu.html
Normal file
98
app/web/templates/_trimitere_detaliu.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
|
||||
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
|
||||
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);"
|
||||
onclick="document.getElementById('trimitere-detaliu').innerHTML='';">
|
||||
Inchide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if stare_subtext %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;">
|
||||
<div><div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">VIN (serie sasiu)</div><div style="word-break:break-all;">{{ prez.vin }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Cod RAR</div><div>{{ prez.cod }}</div></div>
|
||||
<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><div class="muted" style="font-size:12px;">Nr. prezentare RAR</div><div>{{ id_prezentare or '—' }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Cod HTTP RAR</div><div>{{ rar_status_code or '—' }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Reincercari</div><div>{{ retry_count }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Creat</div><div>{{ created_at }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Actualizat</div><div>{{ updated_at }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if motiv %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div class="muted" style="font-size:12px;">Motiv</div>
|
||||
<div>{{ motiv }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if rar_error %}
|
||||
<details style="margin-top:10px;">
|
||||
<summary class="muted" style="font-size:12px; cursor:pointer;">Mesaj tehnic RAR (integral)</summary>
|
||||
<pre style="white-space:pre-wrap; word-break:break-all; font-size:12px; margin:6px 0 0; color:var(--muted);">{{ rar_error }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #}
|
||||
{% if editabil %}
|
||||
{% set err_map = {} %}
|
||||
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
|
||||
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
|
||||
|
||||
{% if corectie_msg %}
|
||||
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
|
||||
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/trimitere/{{ id }}/corecteaza"
|
||||
hx-target="#trimitere-detaliu" hx-swap="innerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
|
||||
|
||||
{% macro camp(nume, eticheta, valoare, tip='text') %}
|
||||
<div>
|
||||
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
|
||||
<input id="c-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare }}"
|
||||
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
|
||||
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}>
|
||||
{% if err_map.get(nume) %}
|
||||
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
|
||||
{{ camp('vin', 'VIN (serie sasiu)', form_vin) }}
|
||||
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }}
|
||||
{{ camp('odometru_final', 'Odometru final', form_odo_final) }}
|
||||
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
|
||||
</div>
|
||||
<div style="margin-top:10px;">
|
||||
<button type="submit">Salveaza si re-pune in coada</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* Vizibilitate (design review): scroll la panou + evidentiaza randul selectat. */
|
||||
var panou = document.getElementById('trimitere-detaliu');
|
||||
if (panou) panou.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
document.querySelectorAll('tr[id^="trimitere-row-"]').forEach(function(tr) {
|
||||
tr.style.outline = '';
|
||||
});
|
||||
var rand = document.getElementById('trimitere-row-{{ id }}');
|
||||
if (rand) rand.style.outline = '2px solid var(--accent)';
|
||||
})();
|
||||
</script>
|
||||
@@ -22,24 +22,25 @@
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{% set tabs = [
|
||||
("acasa", "Acasa", "tab-acasa"),
|
||||
("import", "Import", "tab-import"),
|
||||
("coada", "Coada", "tab-coada"),
|
||||
("coada", "Trimiteri", "tab-coada"),
|
||||
("mapari", "Mapari", "tab-mapari"),
|
||||
("cont", "Cont", "tab-cont"),
|
||||
("nomenclator", "Nomenclator", "tab-nomenclator")
|
||||
] %}
|
||||
{% 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 }}</a>
|
||||
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>
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
|
||||
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
|
||||
|
||||
**Ultima actualizare**: 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP).
|
||||
**Ultima actualizare**: 2026-06-19 — 3.5 LIVRAT (dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP).
|
||||
|
||||
> 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP).
|
||||
|
||||
> 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
|
||||
|
||||
@@ -81,6 +83,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
| 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
| 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | DONE | 2026-06-18 | Dashboard reorganizat in 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=` + panou activ server-side + lazy pe rest; bara status cu etichete umane (`app/web/labels.py`) + defalcare blocate; import ca stepper 4 pasi (PUR vizual); Acasa onboarding auto-bifat + empty states. Backend trimitere neatins. 434 teste. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) |
|
||||
| 3.5 | Dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete | DONE | 2026-06-19 | 11 stories (4 valuri), 3 review-uri de plan facute. Acasa=ecran de import (scoate tab Import); bara status compacta font normal + bife accesibile (auto-send/RAR) + data `dd.mm.yyyy hh24:mi:ss`; "Coada"→"Trimiteri" cu coloane RO + detalii comanda din `payload_json` (helper partajat `payload_view.py`) + detaliu la click in panou dedicat; filtrare Trimiteri (US-009); corectie inline `needs_data` cu re-enqueue + detectie coliziune idempotency (US-010); badge contoare pe tab-uri (US-011); "Mapari" 3 sectiuni (de rezolvat / op salvate cu re-rezolvare auto / formate coloane), "Cont"=doar cheie+creds; feedback `needs_data` la import. Backend trimitere neatins. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md) |
|
||||
|
||||
### Etapa 4 — Viitor (Treapta 3)
|
||||
|
||||
|
||||
495
docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md
Normal file
495
docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# PRD 3.5 — Dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete
|
||||
|
||||
**Stare**: inchis
|
||||
|
||||
> 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).
|
||||
> Aceasta e o livrabila de **UI/UX** — continua 3.4 (`prd-3.4-ux-dashboard-web.md`). Atinge stratul web
|
||||
> (Jinja2 + HTMX, zero build) si rute de prezentare/listare. **Nu** modifica `worker/`, `mapping.py`
|
||||
> (logica de rezolvare), `idempotency.py`, masina de stari submissions sau contractul RAR.
|
||||
|
||||
## 1. Obiectiv
|
||||
|
||||
Feedback de utilizare pe interfata 3.4: e mai buna, dar (a) importul — operatia principala — e ascuns
|
||||
intr-un tab, (b) bara de status are fonturi mici si etichete fara bife, (c) pagina "Coada" e neintuitiva
|
||||
(coloane tehnice "HTTP RAR", stare in engleza "sent", "Motiv" gol, si **nu se vede la ce comanda se
|
||||
refera** — doar `idPrezentare`), (d) pagina "Mapari" arata doar operatiile nerezolvate — maparile deja
|
||||
salvate par pierdute, iar maparile de coloane nu se vad nicaieri.
|
||||
|
||||
Livrabila rezolva aceste patru zone, fara a schimba comportamentul backend de trimitere:
|
||||
|
||||
1. **Acasa = ecranul de import.** Prima pagina arata direct caseta de upload (importul e operatia
|
||||
principala), sub o bara de status compacta; ghidul "primii pasi" si link-urile de ajutor coboara pe
|
||||
un singur rand discret. Tab-ul "Import" separat dispare (era acelasi flux).
|
||||
2. **Bara de status compacta, font normal, cu bife.** Doua randuri: sus doua bife (verde/rosu) pentru
|
||||
"Trimitere automata" si "Legatura RAR" + "Ultima autentificare RAR" in format `dd.mm.yyyy hh24:mi:ss`;
|
||||
jos contoarele (in asteptare / declarate / blocate).
|
||||
3. **"Coada" → "Trimiteri", lizibila.** Coloane umane (Stare in romana via `labels.py`, Vehicul,
|
||||
Operatie, Data prestatie, Nr. prezentare RAR, Actualizat, Motiv uman). Click pe rand → detaliu complet
|
||||
(toate campurile, inclusiv codul HTTP tehnic si motivul integral). Detaliile comenzii se citesc din
|
||||
`payload_json` (text JSON simplu, nu criptat).
|
||||
4. **"Mapari" complet — trei sectiuni.** (1) De rezolvat (`needs_mapping`, ca acum), (2) Mapari operatii
|
||||
salvate — `operations_mapping` editabil (schimba cod RAR / auto-send / sterge), (3) Formate de coloane
|
||||
salvate — `column_mappings` per semnatura (vezi coloanele, format data, editeaza/sterge). "Cont" ramane
|
||||
doar cheie API + creds RAR.
|
||||
|
||||
Decizii de layout confirmate cu utilizatorul (AskUserQuestion, cu preview): Acasa=Import direct; status pe
|
||||
doua randuri cu bife; Trimiteri cu detalii in tabel + expand; toate maparile intr-un singur loc ("Mapari").
|
||||
|
||||
## 2. Non-Goals (anti scope-creep)
|
||||
|
||||
- **Fara schimbari de backend de trimitere**: worker, mapare op→cod (rezolvarea), idempotenta,
|
||||
reconciliere, masina de stari submissions raman neatinse. Doar prezentare + listare/editare web.
|
||||
- **Fara endpoint-uri API noi `/v1/*`** si fara schimbari de schema SQL **de structura**. Tabelele
|
||||
`operations_mapping` / `column_mappings` exista deja; doar le expunem/edita prin rute web. Exceptie
|
||||
controlata (acceptata la CEO review): US-009 poate adauga **un index** pe `submissions` pentru filtrare
|
||||
(nu coloane noi, nu tabele noi).
|
||||
- **Fara framework JS / build step**: ramane Jinja2 + HTMX + CSS in `base.html`. Eventualul JS e vanilla
|
||||
inline, minim. Fara React/Vue/Tailwind/bundler.
|
||||
- **Fara rescriere a fluxului de import** (parsare, mapare coloane, preview, commit raman ca logica) —
|
||||
doar muta upload-ul pe Acasa si imbraca rezultatul.
|
||||
- **Fara redesign login/signup/admin** dincolo de aplicarea acelorasi clase/etichete daca e trivial.
|
||||
- **Fara i18n / tema light**: texte in romana hardcodate, paleta dark din `base.html`.
|
||||
- **Fara modificari de worker / masina de stari / reconciliere**: US-010 (corectie inline) doar
|
||||
re-valideaza (`validation.py`) si re-pune randul in `queued` (re-enqueue), fara sa atinga worker-ul.
|
||||
- **Editarea de continut e permisa DOAR pentru randuri ne-trimise blocate** (`needs_data`/`needs_mapping`),
|
||||
prin US-010. Randurile `sent`/`FINALIZATA` raman **read-only** (terminal la RAR, fara anulare/corectie).
|
||||
|
||||
## 3. Stories atomice
|
||||
|
||||
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
|
||||
> comportament = 2 stories. Toate atingerile sunt in `app/web/` (templates + routes + `labels.py`).
|
||||
> Verificare E2E = browser HTMX pe `http://localhost:8000/` (Playwright MCP sau `/browse`).
|
||||
> **Regula de aur**: fluxul import → commit → worker → FINALIZATA la RAR test NU are voie sa se strice,
|
||||
> iar deep-link-urile `?tab=` raman valide.
|
||||
|
||||
### US-001: Bara de status compacta cu bife + data formatata (backend format + UI)
|
||||
**Ca** operator de service **vreau** o bara de status compacta, cu font normal, cu bife clare si data
|
||||
ultimei autentificari completa **pentru ca** sa vad starea sistemului dintr-o privire, fara sa ghicesc.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/labels.py` (helper format data), `app/web/routes.py` (`fragment_status`),
|
||||
`app/web/templates/_status.html`, `tests/test_web_labels.py`, `tests/test_web_status.py` (~5 fisiere)
|
||||
- **Test intai (RED)**: `test_format_data_rar` — `2026-06-18T14:30:22` (sau forma stocata in
|
||||
`worker_heartbeat.last_rar_login_ok`) → `"18.06.2026 14:30:22"`; valoare lipsa → `"—"`; format invalid
|
||||
→ fallback grijuliu (nu arunca). `tests/test_web_status.py::test_status_are_bife` — fragmentul randat
|
||||
contine bifa verde cand worker viu + RAR ok, rosie cand oprit/indisponibil.
|
||||
- **Continut**:
|
||||
- Helper pur in `labels.py` (ex. `format_data_rar(raw) -> str`) care produce `dd.mm.yyyy hh24:mi:ss`.
|
||||
- `_status.html` rescris pe **doua randuri**: rand 1 = `[bifa] Trimitere automata activa` +
|
||||
`[bifa] Legatura RAR OK` + `Ultima autentificare RAR: <data>`; rand 2 = `In asteptare: N |
|
||||
Declarate la RAR: N | Blocate: N`. Font normal (13-14px), fara `font-size:11px/12px`.
|
||||
- **Accesibilitate (design review)**: starea NU se distinge doar prin culoare. Glifa difera —
|
||||
`✓` (✓) pentru activ/OK, `✗` (✗) pentru oprit/indisponibil — plus textul difera
|
||||
(activa/oprita, functionala/indisponibila). Culoarea (verde/rosu) e redundanta, nu singurul semnal.
|
||||
- Pastreaza avertismentul "cont in asteptare de activare" (regresia reparata in 3.4) si poll-ul 15s.
|
||||
- **Acceptance criteria**:
|
||||
- [x] Data ultimei autentificari apare ca `dd.mm.yyyy hh24:mi:ss` (test pe helper, pur).
|
||||
- [x] Doua stari binare au bifa verde/rosie dupa starea reala (worker viu/mort, RAR ok/indisponibil).
|
||||
- [x] Niciun text din bara nu mai foloseste `font-size` sub 13px.
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser pe `/` — bara compacta, bife corecte, data formatata romaneste.
|
||||
|
||||
### US-002: Acasa devine ecranul de import (upload inline + help compact, scoatere tab Import)
|
||||
**Ca** operator **vreau** sa import direct de pe prima pagina **pentru ca** importul e ce fac cel mai des.
|
||||
|
||||
- **Depinde de**: US-001 (bara status finala deasupra)
|
||||
- **Fisiere**: `app/web/templates/dashboard.html` (lista tab-uri), `app/web/routes.py` (`_TABS_VALIDE`,
|
||||
`_render_panel`, `fragment_import`/`fragment_acasa`), `app/web/templates/_acasa.html`,
|
||||
`app/web/templates/_upload.html` (reutilizat), `tests/test_web_dashboard.py` (~5 fisiere)
|
||||
- **Test intai (RED)**: `test_acasa_contine_upload` — fragmentul `/_fragments/acasa` contine formularul de
|
||||
upload (`hx-post` catre ruta de import existenta); `test_tab_import_redirect` — `?tab=import` nu mai e
|
||||
tab separat (redirect la `acasa` sau eticheta absenta din tab-bar), iar deep-link-ul nu da 404.
|
||||
- **Continut**:
|
||||
- `_acasa.html`: caseta de upload (din `_upload.html`) ca prim element; sub ea, "primii pasi" pe un
|
||||
**singur rand** compact (`o Cont RAR o Cheie API * Import`) + un rand de ajutor cu link-uri mici
|
||||
(Ghid / Coada / Mapari). Upload-ul porneste stepper-ul existent (target `#import-section`).
|
||||
- **Ierarhie (design review)**: upload-ul e vizual DOMINANT (titlu clar + caseta mare); checklist-ul
|
||||
"primii pasi" si ajutorul sunt subordonate (font mai mic, sub upload), nu concureaza cu el. Prima
|
||||
pagina are un singur centru de greutate: importa un fisier.
|
||||
- Scoate `("import", ...)` din lista de tab-uri din `dashboard.html`; pastreaza `?tab=import` valid
|
||||
(redirect la `acasa`) pentru orice URL salvat. Stepper-ul, mapare-coloane, preview, commit raman.
|
||||
- "Incarca alt fisier" din stepper trimite inapoi la Acasa (nu la un tab inexistent).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Pe `/` (tab implicit Acasa) caseta de upload e vizibila fara click suplimentar.
|
||||
- [x] Tab-bar-ul nu mai are "Import"; `?tab=import` nu da 404 (redirect/echivalent la Acasa).
|
||||
- [x] Fluxul upload → mapare coloane → preview → commit functioneaza neschimbat (target/csrf intacte).
|
||||
- [x] Link-urile de ajutor + checklist incap pe randuri compacte, font normal.
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — incarc un fisier de pe Acasa, parcurg stepper-ul pana la commit.
|
||||
|
||||
### US-003: Prezentare detalii comanda din payload (backend pur, testat)
|
||||
**Ca** operator **vreau** ca fiecare trimitere sa-mi spuna despre ce vehicul/operatie e vorba
|
||||
**pentru ca** acum vad doar `idPrezentare` si nu stiu ce comanda am trimis si ce nu.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/payload_view.py` (nou, helper PARTAJAT web+API), `app/api/v1/router.py` (refactor
|
||||
mic: `GET /v1/prezentari` foloseste acelasi helper, nu o copie), `tests/test_payload_view.py`
|
||||
(~3 fisiere). Decizie eng review (DRY): un singur modul de extragere payload→campuri afisabile, ca sa
|
||||
nu diverge intre canalul web si cel API (router.py:247-264 face azi extragerea sa proprie).
|
||||
- **Test intai (RED)**: `test_detalii_din_payload` — dat un `payload_json` (text JSON: `vin`, `numar`/
|
||||
`numarInmatriculare`, `odometru_final`, `data_prestatie`, `cod_prestatie`/`cod_op_service`/`denumire`),
|
||||
helperul intoarce un dict de prezentare `{vehicul_nr, vin_scurt, operatie, data_prestatie, odometru,
|
||||
cod}`; `test_payload_partial` — campuri lipsa → `"—"`/gol fara exceptie; `test_payload_invalid` →
|
||||
fallback grijuliu (nu arunca); `test_payload_coercion_excel` — odometru `"123.0"`/numeric si VIN
|
||||
non-string (coercion Excel) afisate curat (`str()` defensiv), chei API vs import (`numar` vs
|
||||
`numarInmatriculare`) ambele rezolvate.
|
||||
- **Continut**: functie pura care primeste randul submission (sau `payload_json`) si produce campurile
|
||||
de afisat. `vin_scurt` = forma trunchiata pentru tabel (VIN integral ramane in detaliu). Citeste cheile
|
||||
defensiv (canalele API si import pot diferi usor; `payload_json` e plaintext — vezi `router.py`).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Helper pur (fara DB, fara request), 100% acoperit de teste pe cazurile plin/partial/invalid.
|
||||
- [x] Nu arunca niciodata pe payload malformat (degradeaza la `—`).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: — (acoperit de US-004).
|
||||
|
||||
### US-004: "Coada" → "Trimiteri" — tabel lizibil + detaliu la click
|
||||
**Ca** operator **vreau** un tabel de trimiteri pe care il inteleg, cu detaliile comenzii
|
||||
**pentru ca** "HTTP RAR"/"sent"/"Motiv gol" nu-mi spun nimic si nu stiu la ce comanda se refera randul.
|
||||
|
||||
- **Depinde de**: US-003 (prezentare payload), US-001 (`labels.py` pt stare umana)
|
||||
- **Fisiere**: `app/web/templates/_coada.html` (titlu + tab label), `app/web/templates/_submissions.html`,
|
||||
`app/web/templates/dashboard.html` (eticheta tab "Coada"→"Trimiteri", `tab_id` ramane `coada`),
|
||||
`app/web/routes.py` (`fragment_submissions` imbogatit + ruta detaliu `/_fragments/trimitere/{id}`),
|
||||
`app/web/templates/_trimitere_detaliu.html` (nou), `tests/test_web_submissions.py` (~6 fisiere)
|
||||
- **Test intai (RED)**: `test_submissions_coloane_umane` — tabelul contine antete RO (Stare, Vehicul,
|
||||
Operatie, Data prestatie, Nr. prezentare RAR, Motiv) si **nu** mai contine "HTTP RAR" ca antet principal,
|
||||
nici status brut englezesc afisat ca atare (folose `labels.eticheta_stare`); `test_detaliu_trimitere` —
|
||||
`/_fragments/trimitere/{id}` intoarce detaliul complet scoped pe cont (404 cross-account).
|
||||
- **Continut**:
|
||||
- Eticheta tab "Coada" → **"Trimiteri"** (pastreaza `tab_id="coada"` ca deep-link `?tab=coada` sa ramana
|
||||
valid). Titlu sectiune "Trimiteri catre RAR".
|
||||
- `_submissions.html`: coloane = `#`, **Stare** (`eticheta_stare` text RO + pill culoare), **Vehicul**
|
||||
(nr + VIN scurt, din US-003), **Operatie**, **Data prestatie**, **Nr. prezentare RAR** (`id_prezentare`
|
||||
sau `—`), **Actualizat**, **Motiv** (text uman; pt `needs_data` → ex. "lipsa odometru"). Codul HTTP
|
||||
tehnic NU mai e coloana principala — coboara in detaliu.
|
||||
`query`-ul include `payload_json` (pt US-003) pe langa campurile actuale.
|
||||
- Click pe rand → `hx-get="/_fragments/trimitere/{id}"` → `_trimitere_detaliu.html` cu toate campurile
|
||||
(vehicul integral, operatie+cod, odometru, data, stare, `rar_status_code`, `rar_error` integral,
|
||||
retry, timestamps). Scoped pe contul sesiunii.
|
||||
- **Detaliul se randeaza intr-un PANOU DEDICAT** (ex. `#trimitere-detaliu` sub/langa tabel), NU inline
|
||||
in randul din tabel. Motiv (CEO review, Finding #1): `_submissions.html` are `hx-trigger="every 10s"`;
|
||||
un expand inline ar fi sters de poll-ul de refresh. Panoul dedicat nu e prins de poll.
|
||||
- **Vizibilitate (design review)**: la deschidere, panoul trebuie sa fie evident — scroll-to panou
|
||||
si/sau evidentiere a randului selectat in tabel. Altfel pare ca "nu s-a intamplat nimic" (panoul
|
||||
apare sub fold).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Antetele coloanelor sunt in romana; starea afisata e text uman (nu "sent").
|
||||
- [x] Fiecare rand arata vehicul + operatie + data (din payload), nu doar `idPrezentare`.
|
||||
- [x] Click pe rand deschide detaliul complet, scoped pe cont (404 la id-ul altui cont).
|
||||
- [x] Motivul pentru `needs_data`/`error` apare in coloana Motiv (nu gol cand exista `rar_error`).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — tab Trimiteri, identific un rand dupa vehicul, deschid detaliul.
|
||||
|
||||
### US-005: Listare + editare mapari operatii salvate (backend)
|
||||
**Ca** operator **vreau** sa vad si sa pot schimba maparile operatie→cod pe care le-am salvat
|
||||
**pentru ca** acum, dupa ce mapez si trimit, ele dispar din ecran si par pierdute.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/routes.py` (query `operations_mapping` + rute edit/delete),
|
||||
`tests/test_web_mapari_salvate.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `test_lista_mapari_salvate` — intoarce randurile `operations_mapping` ale contului
|
||||
cu `nume_prestatie` jonctionat din nomenclator; `test_editeaza_mapare_salvata` — POST schimba
|
||||
`cod_prestatie`/`auto_send` doar pe contul propriu (cross-account interzis), verifica `cod_prestatie`
|
||||
exista in nomenclator (ca la `/mapari` actual); `test_sterge_mapare_salvata` — DELETE scoped pe cont.
|
||||
- **Continut**: functie de listare scoped pe cont + rute web `POST /mapari/salvate` (update) si
|
||||
`POST /mapari/salvate/sterge` (delete) cu CSRF + PRG/HTMX swap. **Nu** schimba logica de rezolvare din
|
||||
`mapping.py`; doar CRUD pe tabela existenta. **Re-rezolvare obligatorie** (promovata din optional la CEO
|
||||
review): la schimbarea unui cod, submission-urile blocate (`needs_mapping`) pe acel `cod_op_service` se
|
||||
re-rezolva automat, reutilizand helperul de re-rezolvare existent (`reresolve_account`/echivalent) —
|
||||
fara cod nou de rezolvare. Inchide pain-ul "am mapat dar nu vad efectul".
|
||||
- **Acceptance criteria**:
|
||||
- [x] Listarea e scoped pe contul sesiunii (fara leak cross-account — vezi C6 din 3.3a).
|
||||
- [x] Editarea respinge cod inexistent in nomenclator si cont strain.
|
||||
- [x] Stergerea afecteaza doar contul propriu.
|
||||
- [x] La editarea unui cod, submission-urile `needs_mapping` pe acel `cod_op_service` se deblocheaza
|
||||
automat (test: rand blocat → editez maparea → randul trece din `needs_mapping`).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: — (acoperit de US-006).
|
||||
|
||||
### US-006: Listare + editare/stergere formate de coloane salvate (backend)
|
||||
**Ca** operator **vreau** sa vad formatele de import memorate si sa le pot edita/sterge
|
||||
**pentru ca** nu stiu ce coloane sunt retinute si ce se intampla cand vin cu un fisier cu alte coloane.
|
||||
|
||||
- **Depinde de**: —
|
||||
- **Fisiere**: `app/web/routes.py` (query `column_mappings` + rute edit/delete),
|
||||
`tests/test_web_formate_coloane.py` (~2 fisiere)
|
||||
- **Test intai (RED)**: `test_lista_formate_coloane` — intoarce randurile `column_mappings` ale contului
|
||||
cu coloanele (din `signature_coloane`/`json_mapare`), `format_data` si un contor de utilizare (cate
|
||||
`import_batches`/submissions folosesc acea semnatura — best-effort, sau omis daca nu e ieftin);
|
||||
`test_sterge_format_coloane` — DELETE scoped pe cont; `test_editeaza_format_coloane` — POST schimba
|
||||
`json_mapare`/`format_data` pentru o semnatura, scoped pe cont.
|
||||
- **Continut**: listare scoped pe cont + rute `POST /formate-coloane/...` (edit/delete) cu CSRF.
|
||||
Comportament documentat (nu cod nou de import): un fisier cu **alte coloane** = semnatura noua = format
|
||||
nou separat (`UNIQUE (account_id, signature_coloane)`), nu suprascrie; **acelasi antet** = maparea
|
||||
retinuta se reaplica automat la urmatorul import (comportament existent din 2.4).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Listarea arata coloanele fiecarui format + format data, scoped pe cont.
|
||||
- [x] Stergerea/editarea afecteaza doar contul propriu (fara leak cross-account).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: — (acoperit de US-006 UI in US-007).
|
||||
|
||||
### US-007: Pagina "Mapari" cu trei sectiuni (UI)
|
||||
**Ca** operator **vreau** o singura pagina "Mapari" cu tot ce tine de mapari
|
||||
**pentru ca** sa rezolv ce e blocat si sa-mi gestionez maparile salvate (operatii + coloane) intr-un loc.
|
||||
|
||||
- **Depinde de**: US-005, US-006
|
||||
- **Fisiere**: `app/web/templates/_mapari.html`, `app/web/routes.py` (`_render_mapari`/`fragment_mapari`
|
||||
imbogatit sa treaca cele 3 seturi de date), `app/web/templates/_cont.html` (ramane doar cheie API +
|
||||
creds RAR — fara mapari), `tests/test_web_mapari_ui.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `test_mapari_trei_sectiuni` — fragmentul `/_fragments/mapari` contine cele trei
|
||||
sectiuni (De rezolvat / Mapari operatii salvate / Formate de coloane salvate); `test_cont_fara_mapari`
|
||||
— `/_fragments/cont` nu mai contine sectiuni de mapari.
|
||||
- **Continut**:
|
||||
- `_mapari.html` reorganizat pe 3 sectiuni: (1) **De rezolvat** = `pending_unmapped` (ca acum), (2)
|
||||
**Mapari operatii salvate** = lista din US-005, fiecare cu select cod RAR + checkbox auto-send + buton
|
||||
sterge (HTMX swap pe `#mapari-section`), (3) **Formate de coloane salvate** = lista din US-006, fiecare
|
||||
cu coloanele afisate + format data + actiuni edit/sterge. Empty states prietenoase per sectiune.
|
||||
- `_cont.html`: confirma ca nu contine mapari (cheie API + creds RAR doar).
|
||||
- **Acceptance criteria**:
|
||||
- [x] "Mapari" arata cele 3 sectiuni; sectiunile goale au mesaj prietenos, nu lipsesc tacit.
|
||||
- [x] Salvarea/stergerea reincarca sectiunea via HTMX fara reload de pagina.
|
||||
- [x] "Cont" nu mai contine mapari.
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — vad maparile salvate dupa ce am mapat+trimis; editez un cod; vad formatul
|
||||
de coloane al fisierului importat.
|
||||
|
||||
### US-008: Feedback clar pentru randuri respinse la import (lipsa odometru / needs_data)
|
||||
**Ca** operator **vreau** sa inteleg de ce un rand nu s-a importat **pentru ca** am incarcat un fisier
|
||||
fara odometru si pur si simplu "nu s-a importat", fara explicatie.
|
||||
|
||||
- **Depinde de**: US-002 (importul pe Acasa)
|
||||
- **Fisiere**: `app/web/templates/_preview_import.html`, `app/web/routes.py` (mesaj preview, daca e cazul),
|
||||
`tests/test_web_preview_motive.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_preview_arata_motiv_needs_data` — un rand `needs_data` din lipsa odometru
|
||||
apare in preview cu motiv explicit ("lipsa odometru" / mesajul de validare), nu doar numarat la "blocate".
|
||||
- **Continut**: preview-ul de import (cele 6 stari deja existente din 2.5) afiseaza pentru randurile
|
||||
`needs_data`/`needs_review` **motivul** (din validare/`error`), ca operatorul sa stie ce sa corecteze.
|
||||
Reutilizeaza mesajele de validare existente (`validation.py`); fara reguli noi de validare.
|
||||
- **Acceptance criteria**:
|
||||
- [x] Un rand fara odometru apare explicit cu motivul, nu doar in contorul "blocate".
|
||||
- [x] Contoarele preview (ok/needs_data/...) raman corecte.
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — import un fisier fara odometru, vad in preview de ce e blocat randul.
|
||||
|
||||
### US-009: Filtrare/cautare in "Trimiteri" (stare / vehicul / data prestatie)
|
||||
**Ca** operator cu sute de trimiteri **vreau** sa filtrez lista **pentru ca** sa gasesc rapid o comanda
|
||||
sau toate cele blocate, fara sa derulez tot.
|
||||
|
||||
- **Depinde de**: US-003, US-004
|
||||
- **Fisiere**: `app/web/routes.py` (`fragment_submissions` accepta parametri de filtru),
|
||||
`app/web/templates/_coada.html` (controale filtru), `app/web/templates/_submissions.html`,
|
||||
`app/schema.sql` (index pe `submissions(account_id, status)` daca lipseste — exista deja
|
||||
`idx_submissions_account_status`, deci probabil zero schimbari), `tests/test_web_filtrare.py` (~4 fisiere)
|
||||
- **Test intai (RED)**: `test_filtru_stare` — `?status=needs_data` intoarce doar acele randuri (scoped pe
|
||||
cont); `test_filtru_vehicul` — cautare text pe nr/VIN (case-insensitive); `test_filtru_data` — interval
|
||||
`data_prestatie`. Toate scoped pe cont, fara leak.
|
||||
- **Continut**: controale HTMX (select stare + input text vehicul + interval data) care reincarca
|
||||
`/_fragments/submissions` cu query string; filtrarea pe vehicul/data se face dupa parsarea
|
||||
`payload_json` (text JSON), filtrarea pe stare in SQL (foloseste indexul existent). Pastreaza poll-ul
|
||||
(poll-ul re-trimite filtrul curent). Empty state "nimic pe filtrul curent" **+ buton „sterge filtrele"**
|
||||
(design review) cand exista un filtru activ.
|
||||
- **Acceptance criteria**:
|
||||
- [x] Filtrele combina (stare + vehicul + data) si raman aplicate la refresh-ul de 10s.
|
||||
- [x] Filtrarea e scoped pe cont (fara leak cross-account).
|
||||
- [x] Nu necesita coloane/tabele noi (cel mult confirma indexul existent).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — filtrez dupa "blocate" si dupa un numar de inmatriculare.
|
||||
|
||||
### US-010: Corectie inline pentru randuri ne-trimise blocate (needs_data)
|
||||
**Ca** operator **vreau** sa completez un camp lipsa (ex. odometru) direct pe randul blocat si sa-l
|
||||
re-trimit **pentru ca** acum trebuie sa refac tot fisierul de import doar pentru un camp.
|
||||
|
||||
- **Depinde de**: US-003, US-004
|
||||
- **Fisiere**: `app/web/routes.py` (ruta `POST /trimitere/{id}/corecteaza`),
|
||||
`app/web/templates/_trimitere_detaliu.html` (form de corectie pe randurile `needs_data`/`needs_mapping`),
|
||||
`tests/test_web_corectie.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_corectie_needs_data` — un rand `needs_data` din lipsa odometru: completez
|
||||
odometru → re-validare (`validation.py`) → status `queued`, payload actualizat, idempotency recalculata;
|
||||
`test_corectie_sent_interzis` — un rand `sent`/`FINALIZATA` NU poate fi editat (403/refuz, read-only);
|
||||
`test_corectie_coliziune_idempotency` — daca noua cheie coincide cu alt submission existent, corectia se
|
||||
opreste cu mesaj „exista deja o trimitere identica (rand #N)", fara IntegrityError/500 si fara duplicat;
|
||||
`test_corectie_cont_strain` — interzis cross-account.
|
||||
- **Continut**: pe panoul de detaliu (US-004), pentru randuri `needs_data`/`needs_mapping`, un mini-form
|
||||
cu campurile relevante; la submit re-valideaza prin `validation.py` (fara reguli noi), reconstruieste
|
||||
`payload_json` + recalculeaza `idempotency_key` (canonicalize → build_key, ca la enqueue), seteaza
|
||||
status `queued` si `next_attempt_at=now`. **Nu** atinge worker-ul / masina de stari (doar re-enqueue).
|
||||
Randurile `sent`/`FINALIZATA` raman read-only (gard explicit).
|
||||
- **Coliziune idempotency** (decizie eng review): INAINTE de UPDATE, verifica daca noua `idempotency_key`
|
||||
exista deja pe alt submission al contului; daca da, opreste corectia si afiseaza „exista deja o
|
||||
trimitere identica (rand #N)" cu link la acel rand. Fara 500, fara duplicat. (UNIQUE pe
|
||||
`idempotency_key` ar arunca IntegrityError altfel.)
|
||||
- **Loc + eroare (design review)**: formul de corectie traieste IN panoul de detaliu (US-004), nu intr-o
|
||||
sectiune separata. Eroarea de validare se afiseaza clar pe campul invalid (nu un mesaj generic sus).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Un rand `needs_data` corectat valid trece in `queued` cu payload + idempotency actualizate.
|
||||
- [x] Randurile `sent`/`FINALIZATA` nu pot fi editate (gard testat).
|
||||
- [x] Coliziunea de idempotency e prinsa si comunicata clar (rand-duplicat identificat), fara 500.
|
||||
- [x] Corectia respinge date inca invalide (mesaj de validare) si conturi straine.
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — corectez odometru pe un rand blocat, il vad trecand in asteptare;
|
||||
cu worker `--send` pe RAR test, ajunge FINALIZATA.
|
||||
|
||||
### US-011: Badge cu contoare pe tab-uri (atentionari)
|
||||
**Ca** operator **vreau** sa vad pe eticheta tab-ului cate lucruri ma asteapta **pentru ca** sa stiu
|
||||
unde sa intervin fara sa deschid fiecare tab.
|
||||
|
||||
- **Depinde de**: US-007 (mapari), US-004 (trimiteri)
|
||||
- **Fisiere**: `app/web/templates/dashboard.html` (badge pe eticheta tab), `app/web/routes.py`
|
||||
(contoarele deja calculate in `fragment_status`/panel context, transmise la tab-bar),
|
||||
`tests/test_web_badge.py` (~3 fisiere)
|
||||
- **Test intai (RED)**: `test_badge_mapari` — cand exista operatii `needs_mapping`, eticheta "Mapari"
|
||||
poarta un numar; `test_badge_trimiteri_blocate` — cand exista randuri blocate, "Trimiteri" poarta
|
||||
marcaj; `test_badge_zero_ascuns` — fara nimic de rezolvat, niciun badge.
|
||||
- **Continut**: numar mic pe eticheta tab-ului, alimentat din contoarele existente (needs_mapping pt
|
||||
Mapari, blocate pt Trimiteri). Pur prezentare; reutilizeaza ce calculeaza deja status-ul. Accesibil
|
||||
(text in `aria-label`, nu doar culoare).
|
||||
- **Acceptance criteria**:
|
||||
- [x] Badge apare doar cand contorul > 0; dispare la zero.
|
||||
- [x] Numarul e corect si scoped pe cont.
|
||||
- [x] `aria-label`-ul tab-ului include sensul badge-ului (nu doar pastila colorata).
|
||||
- [x] `python3 -m pytest -q` trece.
|
||||
- **Verificare E2E**: browser — cu o operatie nemapata, "Mapari" arata "(1)"; dupa rezolvare, dispare.
|
||||
|
||||
## 4. Riscuri
|
||||
|
||||
- **Scoaterea tab-ului "Import" rupe deep-link-uri/teste** (`?tab=import`, link-uri din `_acasa.html`,
|
||||
"Incarca alt fisier" din stepper). Mitigare: `?tab=import` → redirect la `acasa`; grep dupa toate
|
||||
referintele `tab=import`/`/_fragments/import` inainte de stergere; test dedicat (US-002).
|
||||
- **Citirea `payload_json` pentru detalii** depinde de forma payload-ului, care difera usor intre canalul
|
||||
API si import. Mitigare: helper pur defensiv cu fallback `—` (US-003), testat pe ambele forme; nu se
|
||||
bazeaza pe o cheie obligatorie.
|
||||
- **Leak cross-account** pe noile listari/editari (mapari salvate, formate coloane, detaliu trimitere).
|
||||
Mitigare: toate scoped pe contul sesiunii cu regula NULL→1 (C6/3.3a), test cross-account per ruta noua,
|
||||
re-folosind pattern-ul `account_scope_clause` (3.2).
|
||||
- **Afisare PII (VIN/nr) pe ecran** in Trimiteri. Acceptabil: e proprietatea contului autentificat, scoped
|
||||
pe sesiune; nu se expune in loguri si nu apare in raspunsuri 422 (handler existent in `main.py`).
|
||||
- **Aglomerare "Mapari" cu 3 sectiuni**. Mitigare: empty states + colaps vizual cand o sectiune e goala.
|
||||
|
||||
## 5. Intrebari deschise
|
||||
|
||||
> Se rezolva cu utilizatorul ÎNAINTE de executie (poarta de aprobare PRD).
|
||||
|
||||
- ~~Detaliul trimiterii: expand inline sau panou?~~ **REZOLVAT (CEO review):** panou dedicat
|
||||
`#trimitere-detaliu`, nu inline — altfel poll-ul de 10s sterge expand-ul (vezi US-004).
|
||||
- "Editare" format de coloane: redeschidem editorul de mapare campuri (`_mapcoloane.html`) prefiltrat pe
|
||||
semnatura salvata, sau permitem doar stergere + re-import? (propunere MVP: stergere + vizualizare; edit
|
||||
de campuri = nice-to-have daca incape in story).
|
||||
- Contor de utilizare pe formate de coloane: il afisam (cost de query) sau il omitem in v1? (propunere:
|
||||
omitem daca nu e ieftin — nu e critic).
|
||||
|
||||
## 6. Valuri de executie (graful de dependente)
|
||||
|
||||
```
|
||||
Val 1: [US-001] [US-003] [US-005] [US-006] ← fara dependente, fisiere distincte → paralel (max 3-4)
|
||||
Val 2: [US-002] [US-004] ← US-002←US-001 ; US-004←US-003+US-001
|
||||
Val 3: [US-007] [US-008] [US-009] [US-010] ← US-007←US-005+US-006 ; US-008←US-002 ;
|
||||
US-009←US-003+US-004 ; US-010←US-003+US-004
|
||||
Val 4: [US-011] ← US-011←US-007+US-004 (contoare din ambele)
|
||||
```
|
||||
|
||||
> Atentie la fisiere partajate intre valuri: `routes.py`, `dashboard.html`, `_status.html`,
|
||||
> `_submissions.html` si `_trimitere_detaliu.html` (US-004/009/010) sunt atinse de mai multe stories —
|
||||
> secventiaza-le sau worktree + merge de catre lead (vezi anti-pattern ROADMAP). In special US-009 si
|
||||
> US-010 ating ambele acelasi panou de detaliu/tabel — ruleaza-le secvential, nu in paralel.
|
||||
|
||||
## 7. Review-uri de plan
|
||||
|
||||
### CEO review (2026-06-19) — SELECTIVE EXPANSION
|
||||
|
||||
- **Abordare aleasa**: A (cele 8 stories complete), cu expansiuni cherry-pick acceptate.
|
||||
- **Expansiuni acceptate** (adaugate ca stories): US-009 filtrare/cautare Trimiteri, US-010 corectie
|
||||
inline pentru `needs_data`, US-011 badge contoare pe tab-uri.
|
||||
- **Finding promovat**: re-rezolvarea automata la editarea unei mapari salvate, din "optional" in scope
|
||||
obligatoriu (US-005).
|
||||
- **Finding de robustete inchis**: detaliul Trimiteri merge in **panou dedicat**, nu inline — altfel
|
||||
poll-ul de 10s din `_submissions.html` ar sterge expand-ul (US-004 + §5).
|
||||
- **Schimbare de scope fata de draft**: editarea de continut e acum permisa pentru randuri ne-trimise
|
||||
blocate (US-010); `sent`/`FINALIZATA` raman read-only. Vezi §2 non-goals actualizat.
|
||||
|
||||
### Eng review (2026-06-19)
|
||||
|
||||
- **Step 0**: 11 stories e mult, dar e o livrabila UI sparta in stories atomice TDD (conventia
|
||||
proiectului), nu overbuild. Risc real = contentia pe fisiere partajate, nu numarul. Scope confirmat.
|
||||
- **Decizie idempotency (US-010)**: corectia detecteaza coliziunea de `idempotency_key` INAINTE de UPDATE
|
||||
si o comunica clar (rand-duplicat identificat), fara 500/duplicat.
|
||||
- **Decizie DRY (US-003)**: helper partajat `app/payload_view.py` folosit si de web si de
|
||||
`GET /v1/prezentari` — o singura sursa de extragere payload→campuri.
|
||||
- **Aserțiune adaugata**: test ca `submissions.payload_json` e plaintext (US-003) — daca vreodata se
|
||||
cripteaza, testul cade si stim sa adaptam.
|
||||
- **Plafon perf notat (US-009)**: filtrarea pe vehicul/data parseaza payload in Python per rand; OK la
|
||||
scara actuala, `json_extract()` daca devine necesar. Nu blocheaza.
|
||||
- **Secventiere intarita**: US-004 (schelet tabel+panou) → apoi US-009 si US-010, strict secvential pe
|
||||
`_submissions.html`/`_trimitere_detaliu.html`/`fragment_submissions`. NU paraleliza valul 3 pe ele.
|
||||
|
||||
### Design review (2026-06-19) — rating 7/10 → 9/10 dupa fixuri
|
||||
|
||||
Layout-urile au fost alese vizual cu utilizatorul (mockup-uri ASCII in AskUserQuestion). Patru cerinte
|
||||
de design adaugate ca AC:
|
||||
|
||||
- **Accesibilitate bife (US-001)**: glife distincte (✓/✗) + text, nu doar culoare (daltonism).
|
||||
- **Ierarhie Acasa (US-002)**: upload-ul vizual dominant; checklist + ajutor subordonate.
|
||||
- **Vizibilitate panou (US-004)**: scroll-to / evidentiere rand la deschiderea detaliului.
|
||||
- **Stari de eroare (US-009/010)**: „sterge filtrele" + empty state; eroare de validare pe campul invalid,
|
||||
in panoul de detaliu (decizie utilizator).
|
||||
|
||||
## 8. Raport review-uri de plan (consolidat)
|
||||
|
||||
| Review | Data | Rezultat | Decizii cheie |
|
||||
|---|---|---|---|
|
||||
| CEO (SELECTIVE EXPANSION) | 2026-06-19 | Aprobat cu expansiuni | Abordare A; +US-009/010/011; re-rezolvare US-005 obligatorie; detaliu in panou (nu inline) |
|
||||
| Eng | 2026-06-19 | Aprobat | Coliziune idempotency US-010 detectata pre-UPDATE; helper partajat `payload_view.py`; secventiere val 3 |
|
||||
| Design | 2026-06-19 | Aprobat (9/10) | Bife accesibile; ierarhie Acasa; vizibilitate panou; stari de eroare |
|
||||
|
||||
**Scope final**: 11 stories (US-001…US-011), in 4 valuri. Backend de trimitere (worker, masina de stari,
|
||||
reconciliere, idempotenta ca logica) neatins; singura mutatie de date noua = corectia US-010 (re-enqueue
|
||||
randuri ne-trimise) + posibil un index (US-009). **DECIZII NEREZOLVATE**: niciuna care sa blocheze
|
||||
executia — raman 2 intrebari de finete in §5 (editare format coloane: stergere+vizualizare in MVP;
|
||||
contor utilizare formate: omis daca nu e ieftin), ambele cu propunere si fara impact pe arhitectura.
|
||||
|
||||
Urmatorul pas (ROADMAP §5): `**Stare**: aprobat` → EXECUTE (TDD pe valuri). Poarta umana: aprobarea PRD.
|
||||
|
||||
---
|
||||
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat, ROADMAP §5.6) — 2026-06-19.
|
||||
|
||||
**Verdict global: PASS.** Toate cele 11 stories verificate prin cod + teste. Regresia de aur intacta.
|
||||
Non-Goals respectate.
|
||||
|
||||
- **Suita**: `python3 -m pytest -q` → **483 passed** (de la 434 baseline 3.4; +49 teste noi). Verde.
|
||||
- **PASS/FAIL per story**: toate US-001…US-011 PASS. Dovezi (verificator context curat):
|
||||
- US-001: `format_data_rar` (dd.mm.yyyy hh24:mi:ss, lipsa→"—", invalid→fallback fara exceptie);
|
||||
bife accesibile cu glife `✓`/`✗` + text distinct + culoare redundanta; fara font-size <13px.
|
||||
- US-002: Acasa include upload (`hx-post="/_import/upload"`), tab Import scos, `?tab=import`→Acasa (fara 404).
|
||||
- US-003: `app/payload_view.py` pur/defensiv, refolosit de `GET /v1/prezentari` (DRY), payload_json brut neexpus.
|
||||
- US-004: coloane RO, stare umana, detaliu in panou dedicat `#trimitere-detaliu` (nu inline), 404 cross-account.
|
||||
- US-005/006: CRUD `operations_mapping`/`column_mappings` scoped pe cont, re-rezolvare la edit cod.
|
||||
- US-007: `_fragments/mapari` 3 sectiuni cu empty states; `_fragments/cont` fara mapari.
|
||||
- US-008: preview arata mesajul de validare pentru randuri needs_data.
|
||||
- US-009: filtre stare(SQL)/vehicul/data scoped pe cont; empty state cu "sterge filtrele".
|
||||
- US-010: corectie needs_data→queued cu payload+idempotency recalculate; sent read-only (403);
|
||||
coliziune idempotency prinsa pre-UPDATE; cross-account 404.
|
||||
- US-011: badge Mapari(needs_mapping)/Trimiteri(blocate), ascuns la zero, scoped, aria-label cu sens.
|
||||
- **Regresia de aur**: flux import→commit→coada + canal API `POST /v1/prezentari` intacte
|
||||
(`test_import_ui/e2e`, `test_api`, `test_web_tabs`); deep-link-uri `?tab=` valide.
|
||||
- **Non-Goals**: `git diff --stat` confirma `app/worker/`, `app/idempotency.py`, `app/mapping.py`,
|
||||
`app/schema.sql` NEATINSE; CHECK status pastreaza cele 6 stari; niciun endpoint `/v1/*` nou.
|
||||
- **E2E live RAR**: neprobat in sesiune (fara credentiale RAR live, identic cu 3.4) — recomandata
|
||||
probare manuala `./start.sh test both --send` + browser pe `http://localhost:8000/`.
|
||||
|
||||
### Findings `/code-review` (high) — reparate inainte de inchidere
|
||||
|
||||
1. **Corectie + needs_mapping (sever)**: ruta `POST /trimitere/{id}/corecteaza` re-punea in `queued`
|
||||
fara re-rezolvarea prestatiilor → un cod nemapat putea ajunge la RAR cu `codPrestatie: null`
|
||||
(FINALIZATA ireversibil). **Fix**: re-ruleaza `resolve_prestatii` + `has_no_auto_send` (ca
|
||||
`reresolve_account`); cod nemapat ramane `needs_mapping`. Test: `test_corectie_needs_mapping_nu_ajunge_in_coada`.
|
||||
2. **Filtru dupa LIMIT 200**: cautarea pe vehicul/data rata randuri mai vechi de 200. **Fix**: fara LIMIT
|
||||
in SQL cand filtrul text/data e activ, plafonare dupa filtrare.
|
||||
3. **Coliziune idempotency non-atomica (cursa TOCTOU → 500)**: **Fix**: `try/except sqlite3.IntegrityError`
|
||||
in jurul UPDATE-ului `queued`, mesaj prietenos in loc de 500.
|
||||
4. **Comparatie data non-ISO gresita la filtru**: **Fix**: `_is_iso_date` — compar doar date ISO YYYY-MM-DD.
|
||||
|
||||
Findings de cleanup (scope-clause hand-coded in `_status_counts`/`_get_acasa_context`, `_render_panel_*`
|
||||
duplicate) sunt preexistente din 3.4, in afara scope-ului 3.5 — neatinse intentionat.
|
||||
@@ -83,14 +83,14 @@ def test_submissions_fragment_scoped(env, monkeypatch):
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
r = client.get("/_fragments/submissions")
|
||||
assert r.status_code == 200
|
||||
assert f"<td>{sub_a}</td>" in r.text
|
||||
assert f"<td>{sub_b}</td>" not in r.text
|
||||
assert f'id="trimitere-row-{sub_a}"' in r.text
|
||||
assert f'id="trimitere-row-{sub_b}"' not in r.text
|
||||
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.get("/_fragments/submissions")
|
||||
assert r.status_code == 200
|
||||
assert f"<td>{sub_b}</td>" in r.text
|
||||
assert f"<td>{sub_a}</td>" not in r.text
|
||||
assert f'id="trimitere-row-{sub_b}"' in r.text
|
||||
assert f'id="trimitere-row-{sub_a}"' not in r.text
|
||||
|
||||
|
||||
def test_nelogat_redirect(monkeypatch):
|
||||
|
||||
82
tests/test_payload_view.py
Normal file
82
tests/test_payload_view.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Teste US-003 (PRD 3.5): helper pur payload -> campuri afisabile.
|
||||
|
||||
Helper partajat web + API (DRY, eng review). Defensiv: nu arunca pe payload
|
||||
malformat; tolerant la diferentele de chei intre canale (numar vs
|
||||
numarInmatriculare) si la coercion Excel (odometru "123.0", VIN non-string).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.payload_view import prezentare_din_payload, EMPTY
|
||||
|
||||
|
||||
def test_detalii_din_payload():
|
||||
"""Payload complet -> toate campurile afisabile corecte."""
|
||||
payload = json.dumps({
|
||||
"vin": "WVWZZZ1JZXW000001",
|
||||
"nr_inmatriculare": "B123XYZ",
|
||||
"odometru_final": "123456",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
|
||||
})
|
||||
d = prezentare_din_payload(payload)
|
||||
assert d["vehicul_nr"] == "B123XYZ"
|
||||
assert d["vin"] == "WVWZZZ1JZXW000001"
|
||||
assert "000001" in d["vin_scurt"] # trunchiat dar identificabil
|
||||
assert d["operatie"] == "Reparatie frane"
|
||||
assert d["cod"] == "R-FRANE"
|
||||
assert d["data_prestatie"] == "2026-06-18"
|
||||
assert d["odometru"] == "123456"
|
||||
|
||||
|
||||
def test_payload_partial():
|
||||
"""Campuri lipsa -> EMPTY, fara exceptie."""
|
||||
d = prezentare_din_payload(json.dumps({"vin": "WVWZZZ1JZXW000002"}))
|
||||
assert d["vin"] == "WVWZZZ1JZXW000002"
|
||||
assert d["vehicul_nr"] == EMPTY
|
||||
assert d["operatie"] == EMPTY
|
||||
assert d["cod"] == EMPTY
|
||||
assert d["data_prestatie"] == EMPTY
|
||||
assert d["odometru"] == EMPTY
|
||||
|
||||
|
||||
def test_payload_gol():
|
||||
"""Payload gol / None -> toate EMPTY, fara exceptie."""
|
||||
for p in (None, "", "{}", {}):
|
||||
d = prezentare_din_payload(p)
|
||||
assert d["vehicul_nr"] == EMPTY
|
||||
assert d["vin"] == EMPTY
|
||||
|
||||
|
||||
def test_payload_invalid():
|
||||
"""JSON invalid / tip neasteptat -> fallback grijuliu (nu arunca)."""
|
||||
for bad in ("nu-e-json", "[1,2,3]", "null", "12345"):
|
||||
d = prezentare_din_payload(bad)
|
||||
assert d["vin"] == EMPTY # degradeaza curat
|
||||
|
||||
|
||||
def test_payload_coercion_excel():
|
||||
"""Odometru '123.0'/numeric si VIN non-string afisate curat; chei API alternative."""
|
||||
# Excel coercion: odometru float-string si numeric
|
||||
d1 = prezentare_din_payload({"odometru_final": "123456.0"})
|
||||
assert d1["odometru"] == "123456"
|
||||
d2 = prezentare_din_payload({"odometru_final": 123456})
|
||||
assert d2["odometru"] == "123456"
|
||||
# VIN non-string (coercion Excel)
|
||||
d3 = prezentare_din_payload({"vin": 12345678901234567})
|
||||
assert d3["vin"] == "12345678901234567"
|
||||
# Chei alternative canal API (numar / numarInmatriculare / odometru)
|
||||
d4 = prezentare_din_payload({"numar": "CJ99ABC", "odometru": "777.0"})
|
||||
assert d4["vehicul_nr"] == "CJ99ABC"
|
||||
assert d4["odometru"] == "777"
|
||||
d5 = prezentare_din_payload({"numarInmatriculare": "TM01AAA"})
|
||||
assert d5["vehicul_nr"] == "TM01AAA"
|
||||
|
||||
|
||||
def test_operatie_fallback_la_cod():
|
||||
"""Fara denumire -> operatie afiseaza codul; cod intern cand lipseste cel RAR."""
|
||||
d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]})
|
||||
assert d["cod"] == "OP-77"
|
||||
assert d["operatie"] == "OP-77"
|
||||
123
tests/test_web_badge.py
Normal file
123
tests/test_web_badge.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Teste US-011 (PRD 3.5): badge cu contoare pe tab-uri (atentionari).
|
||||
|
||||
Badge doar cand contorul > 0; numar corect scoped pe cont; aria-label cu sens.
|
||||
"""
|
||||
|
||||
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) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(f"k-{os.urandom(5).hex()}", acct, status, json.dumps({"vin": "X", "prestatii": []})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _tab_link(html: str, elem_id: str) -> str:
|
||||
"""Extrage tag-ul <a ...>...</a> al tab-ului cu id-ul dat."""
|
||||
m = re.search(rf'<a id="{elem_id}".*?</a>', html, re.DOTALL)
|
||||
assert m, f"Tab {elem_id} negasit"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge.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_badge_mapari(client):
|
||||
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
|
||||
acct = _create_account_user("bm@test.com")
|
||||
_ins(acct, "needs_mapping")
|
||||
_ins(acct, "needs_mapping")
|
||||
_login(client, "bm@test.com")
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_badge_trimiteri_blocate(client):
|
||||
"""Cu randuri blocate, tab-ul Trimiteri poarta marcaj."""
|
||||
acct = _create_account_user("bt@test.com")
|
||||
_ins(acct, "needs_data")
|
||||
_ins(acct, "error")
|
||||
_login(client, "bt@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
link = _tab_link(resp.text, "tab-coada")
|
||||
assert "tab-badge" in link
|
||||
assert "2" in link
|
||||
|
||||
|
||||
def test_badge_zero_ascuns(client):
|
||||
"""Fara nimic de rezolvat, niciun badge."""
|
||||
_create_account_user("bz@test.com")
|
||||
_login(client, "bz@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "tab-badge" not in resp.text
|
||||
|
||||
|
||||
def test_badge_scoped_pe_cont(client):
|
||||
"""Badge-ul numara doar submission-urile contului propriu."""
|
||||
acct1 = _create_account_user("bs1@test.com", name="C1")
|
||||
_create_account_user("bs2@test.com", name="C2")
|
||||
_ins(acct1, "needs_mapping")
|
||||
|
||||
_login(client, "bs2@test.com")
|
||||
resp = client.get("/")
|
||||
assert "tab-badge" not in resp.text # contul 2 nu are nimic
|
||||
205
tests/test_web_corectie.py
Normal file
205
tests/test_web_corectie.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Teste US-010 (PRD 3.5): corectie inline pentru randuri ne-trimise blocate.
|
||||
|
||||
needs_data corectat valid -> queued cu payload + idempotency actualizate; sent
|
||||
read-only (403); coliziune de idempotency prinsa pre-UPDATE (fara 500/duplicat);
|
||||
cross-account interzis (404).
|
||||
"""
|
||||
|
||||
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("/?tab=coada")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
k = key or f"k-{os.urandom(6).hex()}"
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(k, acct, status, json.dumps(payload)),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
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 _payload(vin: str, *, odo: str = "55000") -> dict:
|
||||
return {
|
||||
"vin": vin, "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10",
|
||||
"odometru_final": odo, "prestatii": [{"cod_prestatie": "R-X"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "corectie.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_corectie_needs_data(client):
|
||||
"""needs_data fara odometru -> completez odometru -> queued, payload + key actualizate."""
|
||||
acct = _create_account_user("cd@test.com")
|
||||
# needs_data: odometru gol
|
||||
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CD001", odo=""))
|
||||
old_key = _row(sid)["idempotency_key"]
|
||||
_login(client, "cd@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
|
||||
"odometru_final": "77000", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
r = _row(sid)
|
||||
assert r["status"] == "queued"
|
||||
assert json.loads(r["payload_json"])["odometru_final"] == "77000"
|
||||
assert r["idempotency_key"] != old_key # recalculata
|
||||
assert r["rar_error"] is None
|
||||
|
||||
|
||||
def test_corectie_inca_invalid_ramane_blocat(client):
|
||||
"""Corectie cu date inca invalide -> ramane needs_data + mesaj de validare."""
|
||||
acct = _create_account_user("ci@test.com")
|
||||
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CI001", odo=""))
|
||||
_login(client, "ci@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# odometru tot invalid (non-numeric)
|
||||
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
|
||||
"odometru_final": "abc", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _row(sid)["status"] == "needs_data"
|
||||
assert "odometruFinal" in resp.text # mesajul de validare e afisat
|
||||
|
||||
|
||||
def test_corectie_sent_interzis(client):
|
||||
"""Randurile sent NU pot fi editate (read-only -> 403)."""
|
||||
acct = _create_account_user("cs@test.com")
|
||||
sid = _insert(acct, status="sent", payload=_payload("WVWZZZ1JZXW0CS001"))
|
||||
_login(client, "cs@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
|
||||
"odometru_final": "88000", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
assert _row(sid)["status"] == "sent" # neschimbat
|
||||
|
||||
|
||||
def test_corectie_coliziune_idempotency(client):
|
||||
"""Daca noua cheie coincide cu alt submission -> oprire cu mesaj, fara 500/duplicat."""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
acct = _create_account_user("cc@test.com")
|
||||
|
||||
target = _payload("WVWZZZ1JZXW0CC999", odo="99000")
|
||||
existing_key = build_key(acct, canonicalize_row(target))
|
||||
# B: submission existent cu cheia tinta
|
||||
sid_b = _insert(acct, status="queued", payload=target, key=existing_key)
|
||||
# A: needs_data, acelasi continut dar fara odometru
|
||||
sid_a = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CC999", odo=""))
|
||||
|
||||
_login(client, "cc@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
resp = client.post(f"/trimitere/{sid_a}/corecteaza", data={
|
||||
"odometru_final": "99000", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "deja o trimitere identica" in resp.text
|
||||
assert f"#{sid_b}" in resp.text
|
||||
# A NU a fost re-pus in coada (a ramas blocat), B neatins
|
||||
assert _row(sid_a)["status"] == "needs_data"
|
||||
assert _row(sid_b)["idempotency_key"] == existing_key
|
||||
|
||||
|
||||
def test_corectie_needs_mapping_nu_ajunge_in_coada(client):
|
||||
"""Un rand needs_mapping cu cod nemapat NU trece in queued la corectie de continut
|
||||
(altfel ar pleca la RAR cu codPrestatie null — FINALIZATA ireversibil)."""
|
||||
acct = _create_account_user("cm@test.com")
|
||||
payload = {
|
||||
"vin": "WVWZZZ1JZXW0CM001", "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10",
|
||||
"odometru_final": "", "prestatii": [{"cod_op_service": "OP-NEMAP", "denumire": "ceva"}],
|
||||
}
|
||||
sid = _insert(acct, status="needs_mapping", payload=payload)
|
||||
_login(client, "cm@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# completez odometru, dar codul ramane nemapat
|
||||
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
|
||||
"odometru_final": "70000", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _row(sid)["status"] == "needs_mapping" # NU queued
|
||||
assert "cod RAR" in resp.text.lower() or "mapari" in resp.text.lower()
|
||||
|
||||
|
||||
def test_corectie_cont_strain(client):
|
||||
"""Corectie pe randul altui cont -> 404 (fara leak)."""
|
||||
acct1 = _create_account_user("ca1@test.com", name="C1")
|
||||
_create_account_user("ca2@test.com", name="C2")
|
||||
sid1 = _insert(acct1, status="needs_data", payload=_payload("WVWZZZ1JZXW0CA001", odo=""))
|
||||
|
||||
_login(client, "ca2@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post(f"/trimitere/{sid1}/corecteaza", data={
|
||||
"odometru_final": "10000", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert _row(sid1)["status"] == "needs_data" # neatins
|
||||
100
tests/test_web_dashboard.py
Normal file
100
tests/test_web_dashboard.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Teste US-002 (PRD 3.5): Acasa devine ecranul de import.
|
||||
|
||||
Upload direct pe prima pagina (importul = operatia principala); tab-ul "Import"
|
||||
separat dispare, dar ?tab=import ramane valid (echivalent Acasa, fara 404).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str = "dash@test.com", 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 Dash", 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, "dash.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_acasa_contine_upload(client):
|
||||
"""Fragmentul /_fragments/acasa contine formularul de upload (hx-post import)."""
|
||||
_create_account_user("acasaup@test.com")
|
||||
_login(client, "acasaup@test.com")
|
||||
|
||||
resp = client.get("/_fragments/acasa")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'hx-post="/_import/upload"' in html, "Acasa nu contine formularul de upload"
|
||||
assert 'id="import-section"' in html, "Acasa nu contine zona de import"
|
||||
|
||||
|
||||
def test_acasa_full_load_contine_upload(client):
|
||||
"""La full load pe / (tab implicit Acasa) caseta de upload e vizibila direct."""
|
||||
_create_account_user("acasafull@test.com")
|
||||
_login(client, "acasafull@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert 'hx-post="/_import/upload"' in resp.text
|
||||
|
||||
|
||||
def test_tab_import_redirect(client):
|
||||
"""?tab=import nu da 404; randeaza Acasa (echivalent), care contine upload-ul."""
|
||||
_create_account_user("redir@test.com")
|
||||
_login(client, "redir@test.com")
|
||||
|
||||
resp = client.get("/?tab=import")
|
||||
assert resp.status_code == 200
|
||||
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"
|
||||
|
||||
|
||||
def test_tab_bar_fara_import(client):
|
||||
"""Tab-bar-ul nu mai contine un tab 'Import' separat."""
|
||||
_create_account_user("notab@test.com")
|
||||
_login(client, "notab@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', resp.text)
|
||||
141
tests/test_web_filtrare.py
Normal file
141
tests/test_web_filtrare.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Teste US-009 (PRD 3.5): filtrare/cautare in Trimiteri (stare/vehicul/data).
|
||||
|
||||
Toate scoped pe cont, fara leak cross-account.
|
||||
"""
|
||||
|
||||
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, 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 int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre.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_stare(client):
|
||||
acct = _create_account_user("fs@test.com")
|
||||
sid_q = _ins(acct, status="queued", vin="WVWZZZ1JZXW000001", nr="B001AAA", data="2026-06-10")
|
||||
sid_nd = _ins(acct, status="needs_data", vin="WVWZZZ1JZXW000002", nr="B002BBB", data="2026-06-11")
|
||||
_login(client, "fs@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?status=needs_data")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert f'id="trimitere-row-{sid_nd}"' in html
|
||||
assert f'id="trimitere-row-{sid_q}"' not in html
|
||||
|
||||
|
||||
def test_filtru_vehicul(client):
|
||||
acct = _create_account_user("fv@test.com")
|
||||
sid_a = _ins(acct, status="sent", vin="WVWZZZ1JZXW000111", nr="CJ77ABC", data="2026-06-10")
|
||||
sid_b = _ins(acct, status="sent", vin="WVWZZZ1JZXW000222", nr="TM01XYZ", data="2026-06-11")
|
||||
_login(client, "fv@test.com")
|
||||
|
||||
# cautare pe nr (case-insensitive)
|
||||
resp = client.get("/_fragments/submissions?vehicul=cj77")
|
||||
assert resp.status_code == 200
|
||||
assert f'id="trimitere-row-{sid_a}"' in resp.text
|
||||
assert f'id="trimitere-row-{sid_b}"' not in resp.text
|
||||
|
||||
# cautare pe fragment VIN
|
||||
resp = client.get("/_fragments/submissions?vehicul=000222")
|
||||
assert f'id="trimitere-row-{sid_b}"' in resp.text
|
||||
assert f'id="trimitere-row-{sid_a}"' not in resp.text
|
||||
|
||||
|
||||
def test_filtru_data(client):
|
||||
acct = _create_account_user("fd@test.com")
|
||||
sid_old = _ins(acct, status="sent", vin="WVWZZZ1JZXW000333", nr="B1", data="2026-06-01")
|
||||
sid_new = _ins(acct, status="sent", vin="WVWZZZ1JZXW000444", nr="B2", data="2026-06-20")
|
||||
_login(client, "fd@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_de=2026-06-15")
|
||||
assert resp.status_code == 200
|
||||
assert f'id="trimitere-row-{sid_new}"' in resp.text
|
||||
assert f'id="trimitere-row-{sid_old}"' not in resp.text
|
||||
|
||||
resp = client.get("/_fragments/submissions?data_pana=2026-06-10")
|
||||
assert f'id="trimitere-row-{sid_old}"' in resp.text
|
||||
assert f'id="trimitere-row-{sid_new}"' not in resp.text
|
||||
|
||||
|
||||
def test_filtru_scoped_cross_account(client):
|
||||
acct1 = _create_account_user("fc1@test.com", name="C1")
|
||||
acct2 = _create_account_user("fc2@test.com", name="C2")
|
||||
sid1 = _ins(acct1, status="needs_data", vin="WVWZZZ1JZXW000555", nr="B1", data="2026-06-10")
|
||||
sid2 = _ins(acct2, status="needs_data", vin="WVWZZZ1JZXW000666", nr="B2", data="2026-06-10")
|
||||
|
||||
_login(client, "fc2@test.com")
|
||||
resp = client.get("/_fragments/submissions?status=needs_data")
|
||||
assert f'id="trimitere-row-{sid2}"' in resp.text
|
||||
assert f'id="trimitere-row-{sid1}"' not in resp.text
|
||||
|
||||
|
||||
def test_empty_state_filtru_are_buton_sterge(client):
|
||||
acct = _create_account_user("fe@test.com")
|
||||
_ins(acct, status="sent", vin="WVWZZZ1JZXW000777", nr="B1", data="2026-06-10")
|
||||
_login(client, "fe@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions?status=needs_data")
|
||||
assert resp.status_code == 200
|
||||
assert "Nimic pe filtrul curent" in resp.text
|
||||
assert "sterge filtrele" in resp.text
|
||||
145
tests/test_web_formate_coloane.py
Normal file
145
tests/test_web_formate_coloane.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Teste US-006 (PRD 3.5): listare + editare/stergere formate de coloane salvate.
|
||||
|
||||
Scoped pe cont (fara leak cross-account). Coloanele afisate = cheile json_mapare.
|
||||
"""
|
||||
|
||||
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("/?tab=mapari")
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
|
||||
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _seed_format(acct: int, sig: str, mapare: dict, fmt: str | None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(acct, sig, json.dumps(mapare, ensure_ascii=False), fmt),
|
||||
)
|
||||
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, "formate.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_lista_formate_coloane(client):
|
||||
"""Listarea intoarce formatele contului cu coloane + format_data."""
|
||||
acct = _create_account_user("lf@test.com")
|
||||
_seed_format(acct, "sig-1", {"Serie sasiu": "vin", "Nr auto": "nr_inmatriculare"}, "DD.MM.YYYY")
|
||||
|
||||
from app.db import get_connection
|
||||
from app.web.routes import _load_column_formats
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = _load_column_formats(conn, acct)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["format_data"] == "DD.MM.YYYY"
|
||||
assert "Serie sasiu" in rows[0]["columns"]
|
||||
assert rows[0]["mappings"]["Serie sasiu"] == "vin"
|
||||
|
||||
|
||||
def test_editeaza_format_coloane(client):
|
||||
"""POST schimba format_data pentru un format, scoped pe cont."""
|
||||
acct = _create_account_user("ef@test.com")
|
||||
fid = _seed_format(acct, "sig-2", {"Data": "data_prestatie"}, "DD.MM.YYYY")
|
||||
|
||||
_login(client, "ef@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/formate-coloane/editeaza", data={
|
||||
"format_id": str(fid), "format_data": "YYYY-MM-DD", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT format_data FROM column_mappings WHERE id=?", (fid,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row["format_data"] == "YYYY-MM-DD"
|
||||
|
||||
|
||||
def test_sterge_format_coloane_scoped(client):
|
||||
"""DELETE scoped pe cont: formatul altui cont ramane neatins (id strain ignorat)."""
|
||||
acct1 = _create_account_user("sf1@test.com", name="C1")
|
||||
acct2 = _create_account_user("sf2@test.com", name="C2")
|
||||
fid1 = _seed_format(acct1, "sig-a", {"A": "vin"}, None)
|
||||
fid2 = _seed_format(acct2, "sig-b", {"B": "vin"}, None)
|
||||
|
||||
_login(client, "sf1@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# Incearca sa stearga formatul altui cont -> ignorat (scoped pe id+account)
|
||||
resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid2), "csrf_token": csrf})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Sterge formatul propriu -> ok
|
||||
resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid1), "csrf_token": csrf})
|
||||
assert resp.status_code == 200
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
r1 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid1,)).fetchone()
|
||||
r2 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid2,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert r1 is None, "formatul propriu trebuia sters"
|
||||
assert r2 is not None, "formatul altui cont NU trebuia sters (leak)"
|
||||
@@ -46,7 +46,12 @@ def _starile_din_schema() -> list[str]:
|
||||
# Import modulul de etichete (va esua la RED, inainte de implementare)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402
|
||||
from app.web.labels import ( # noqa: E402
|
||||
eticheta_stare,
|
||||
eticheta_worker,
|
||||
eticheta_rar,
|
||||
format_data_rar,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -128,6 +133,35 @@ def test_eticheta_stare_submission():
|
||||
_STARI_SCHEMA = _starile_din_schema()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test format_data_rar (US-001, PRD 3.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_format_data_rar():
|
||||
"""`2026-06-18T14:30:22` -> `18.06.2026 14:30:22`."""
|
||||
assert format_data_rar("2026-06-18T14:30:22") == "18.06.2026 14:30:22"
|
||||
|
||||
|
||||
def test_format_data_rar_cu_timezone():
|
||||
"""Timezone si microsecunde nu strica formatarea; fractiunile cad."""
|
||||
assert format_data_rar("2026-06-18T14:30:22.123456+00:00") == "18.06.2026 14:30:22"
|
||||
assert format_data_rar("2026-06-18T14:30:22Z") == "18.06.2026 14:30:22"
|
||||
|
||||
|
||||
def test_format_data_rar_lipsa():
|
||||
"""Valoare lipsa -> em-dash, nu exceptie."""
|
||||
assert format_data_rar(None) == "—"
|
||||
assert format_data_rar("") == "—"
|
||||
assert format_data_rar(" ") == "—"
|
||||
|
||||
|
||||
def test_format_data_rar_invalid():
|
||||
"""Format invalid -> fallback grijuliu (intoarce brutul, nu arunca)."""
|
||||
# Nu trebuie sa arunce
|
||||
assert format_data_rar("nu-e-data") == "nu-e-data"
|
||||
assert format_data_rar(12345) == "12345"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", _STARI_SCHEMA)
|
||||
def test_toate_starile_au_eticheta(status: str):
|
||||
"""
|
||||
|
||||
219
tests/test_web_mapari_salvate.py
Normal file
219
tests/test_web_mapari_salvate.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Teste US-005 (PRD 3.5): listare + editare/stergere mapari operatii salvate.
|
||||
|
||||
Scoped pe cont (fara leak cross-account). Editarea respinge cod inexistent in
|
||||
nomenclator si re-rezolva submission-urile blocate pe acel cod_op_service.
|
||||
"""
|
||||
|
||||
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 _csrf(client) -> str:
|
||||
resp = client.get("/?tab=mapari")
|
||||
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"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _seed_nomenclator(cod: str, nume: str = "Test prestatie") -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
(cod, nume),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_op_mapping(acct: int, op: str, cod: str, auto_send: int = 1) -> None:
|
||||
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, op, cod, auto_send),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_needs_mapping(acct: int, op: str) -> int:
|
||||
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
|
||||
from app.db import get_connection
|
||||
import json
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?)",
|
||||
(
|
||||
f"k-{op}-{os.urandom(4).hex()}",
|
||||
acct,
|
||||
json.dumps({
|
||||
"vin": "WVWZZZ1JZXW000111",
|
||||
"nr_inmatriculare": "B11AAA",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "12345",
|
||||
"prestatii": [{"cod_op_service": op, "denumire": "ceva"}],
|
||||
}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _status_of(sid: int) -> str:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_salvate.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_lista_mapari_salvate(client):
|
||||
"""Listarea intoarce randurile operations_mapping ale contului cu nume_prestatie."""
|
||||
acct = _create_account_user("lista@test.com")
|
||||
_seed_nomenclator("R-FRANE", "Reparatie frane")
|
||||
_seed_op_mapping(acct, "OP-100", "R-FRANE")
|
||||
|
||||
from app.db import get_connection
|
||||
from app.web.routes import _load_saved_op_mappings
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = _load_saved_op_mappings(conn, acct)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["cod_op_service"] == "OP-100"
|
||||
assert rows[0]["cod_prestatie"] == "R-FRANE"
|
||||
assert rows[0]["nume_prestatie"] == "Reparatie frane"
|
||||
assert rows[0]["auto_send"] is True
|
||||
|
||||
|
||||
def test_editeaza_mapare_salvata(client):
|
||||
"""POST schimba cod_prestatie; respinge cod inexistent; scoped pe cont."""
|
||||
acct = _create_account_user("edit@test.com")
|
||||
_seed_nomenclator("R-FRANE", "Reparatie frane")
|
||||
_seed_nomenclator("R-MOTOR", "Reparatie motor")
|
||||
_seed_op_mapping(acct, "OP-100", "R-FRANE")
|
||||
_login(client, "edit@test.com")
|
||||
csrf = _csrf(client)
|
||||
|
||||
# Cod inexistent -> respins
|
||||
resp = client.post("/mapari/salvate", data={
|
||||
"cod_op_service": "OP-100", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "necunoscut" in resp.text.lower()
|
||||
|
||||
# Cod valid -> actualizat
|
||||
resp = client.post("/mapari/salvate", data={
|
||||
"cod_op_service": "OP-100", "cod_prestatie": "R-MOTOR", "auto_send": "true", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
|
||||
(acct, "OP-100"),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row["cod_prestatie"] == "R-MOTOR"
|
||||
|
||||
|
||||
def test_editeaza_deblocheaza_submissions(client):
|
||||
"""La editarea unui cod, submission-urile needs_mapping pe acel op se deblocheaza."""
|
||||
acct = _create_account_user("debloc@test.com")
|
||||
_seed_nomenclator("R-FRANE", "Reparatie frane")
|
||||
sid = _seed_needs_mapping(acct, "OP-200")
|
||||
assert _status_of(sid) == "needs_mapping"
|
||||
|
||||
_login(client, "debloc@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/salvate", data={
|
||||
"cod_op_service": "OP-200", "cod_prestatie": "R-FRANE", "auto_send": "true", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _status_of(sid) != "needs_mapping" # deblocat (queued sau needs_data)
|
||||
|
||||
|
||||
def test_sterge_mapare_salvata_scoped(client):
|
||||
"""DELETE scoped pe cont: maparea altui cont ramane neatinsa."""
|
||||
acct1 = _create_account_user("st1@test.com", name="Cont1")
|
||||
acct2 = _create_account_user("st2@test.com", name="Cont2")
|
||||
_seed_nomenclator("R-FRANE")
|
||||
_seed_op_mapping(acct1, "OP-X", "R-FRANE")
|
||||
_seed_op_mapping(acct2, "OP-X", "R-FRANE")
|
||||
|
||||
_login(client, "st1@test.com")
|
||||
csrf = _csrf(client)
|
||||
resp = client.post("/mapari/salvate/sterge", data={"cod_op_service": "OP-X", "csrf_token": csrf})
|
||||
assert resp.status_code == 200
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
r1 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct1,)).fetchone()
|
||||
r2 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct2,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert r1 is None, "maparea contului propriu trebuia stearsa"
|
||||
assert r2 is not None, "maparea altui cont NU trebuia atinsa (leak)"
|
||||
116
tests/test_web_mapari_ui.py
Normal file
116
tests/test_web_mapari_ui.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Teste US-007 (PRD 3.5): pagina "Mapari" cu 3 sectiuni; "Cont" fara mapari."""
|
||||
|
||||
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 _seed(acct: int) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, 'OP-1', 'R-FRANE', 1)",
|
||||
(acct,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) VALUES (?, 'sig-x', ?, 'DD.MM.YYYY')",
|
||||
(acct, json.dumps({"Serie sasiu": "vin"})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_ui.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_mapari_trei_sectiuni(client):
|
||||
"""Fragmentul /_fragments/mapari contine cele 3 sectiuni."""
|
||||
acct = _create_account_user("m3@test.com")
|
||||
_seed(acct)
|
||||
_login(client, "m3@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "De rezolvat" in html
|
||||
assert "Mapari operatii salvate" in html
|
||||
assert "Formate de coloane salvate" in html
|
||||
# Maparea salvata si formatul apar
|
||||
assert "OP-1" in html
|
||||
assert "Serie sasiu" in html
|
||||
|
||||
|
||||
def test_mapari_sectiuni_goale_au_mesaj(client):
|
||||
"""Sectiunile goale au mesaj prietenos, nu lipsesc tacit."""
|
||||
_create_account_user("mgol@test.com")
|
||||
_login(client, "mgol@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Toate cele 3 titluri prezente chiar si cand sunt goale
|
||||
assert "De rezolvat" in html
|
||||
assert "Mapari operatii salvate" in html
|
||||
assert "Formate de coloane salvate" in html
|
||||
assert "Nicio mapare salvata" in html
|
||||
assert "Niciun format de coloane salvat" in html
|
||||
|
||||
|
||||
def test_cont_fara_mapari(client):
|
||||
"""/_fragments/cont nu mai contine sectiuni de mapari."""
|
||||
_create_account_user("cfm@test.com")
|
||||
_login(client, "cfm@test.com")
|
||||
|
||||
resp = client.get("/_fragments/cont")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "Mapari operatii salvate" not in html
|
||||
assert "Formate de coloane salvate" not in html
|
||||
# Cont contine doar cheie API + creds RAR
|
||||
assert "Cheia mea API" in html
|
||||
assert "Credentiale RAR" in html
|
||||
@@ -151,13 +151,10 @@ def test_checklist_pas_creds_bifat_cand_exista(client):
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Cand exista creds, pasul trebuie sa fie bifat
|
||||
# Verificam prezenta unui indicator de bifat (clasa 'bifat' sau 'pas-bifat' sau 'done')
|
||||
# Cel putin unul dintre pattern-urile de bifat trebuie sa apara
|
||||
assert re.search(
|
||||
r'pas-bifat|class="[^"]*bifat|done.*RAR|RAR.*done|checkmark.*RAR|RAR.*checkmark',
|
||||
html, re.DOTALL | re.IGNORECASE
|
||||
), "Pasul RAR trebuie sa fie bifat cand contul are creds configurate"
|
||||
# Cand exista creds, pasul "Cont RAR" e bifat: glifa ✓ (s-sent) langa link-ul Cont RAR
|
||||
# (Acasa compacta PRD 3.5 — checklist pe un rand, bife cu glifa).
|
||||
assert "✓" in html, "Lipseste glifa de bifat cand contul are creds"
|
||||
assert "Cont RAR" in html, "Lipseste pasul 'Cont RAR' din checklist"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -175,17 +172,12 @@ def test_checklist_ascuns_cand_totul_gata(client):
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Cand totul e gata, ghidul compact/discret trebuie sa apara
|
||||
# Fie "Totul e configurat" fie un link discret catre coada
|
||||
assert "Totul e configurat" in html or "totul e configurat" in html.lower(), \
|
||||
"Cand toti pasii sunt gata, trebuie sa apara mesajul discret 'Totul e configurat'"
|
||||
|
||||
# Cardul mare de pasi nu trebuie sa ocupe ecranul
|
||||
# Verificam ca nu mai apare titlul mare al ghidului (Primii pasi)
|
||||
# SAU ca ghidul e marcat ca colapsat (clasa 'ghid-complet' sau similar)
|
||||
# Pattern: fie ghid-complet, fie lipsa titlului complet "Primii pasi" in forma de card mare
|
||||
assert "ghid-complet" in html or "Totul e configurat" in html, \
|
||||
"Ghidul trebuie sa se colapseze cand toti pasii esentiali sunt finalizati"
|
||||
# Cand toti pasii esentiali sunt gata, checklist-ul "Primii pasi" dispare
|
||||
# (Acasa compacta PRD 3.5: nu mai concureaza cu caseta de upload).
|
||||
assert "Primii pasi" not in html, \
|
||||
"Checklist-ul 'Primii pasi' trebuie sa dispara cand toti pasii esentiali sunt gata"
|
||||
# Upload-ul ramane dominant pe pagina chiar si dupa setup complet
|
||||
assert 'hx-post="/_import/upload"' in html
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -193,7 +185,7 @@ def test_checklist_ascuns_cand_totul_gata(client):
|
||||
# ============================================================
|
||||
|
||||
def test_linkuri_ghid_duc_la_taburi(client):
|
||||
"""Link-urile din ghid contin ?tab=cont si ?tab=import."""
|
||||
"""Ghidul Acasa duce la Cont; importul e direct pe pagina (nu mai e tab separat)."""
|
||||
acct_id, _ = _create_account_user("links@test.com")
|
||||
_login(client, "links@test.com")
|
||||
|
||||
@@ -205,9 +197,9 @@ def test_linkuri_ghid_duc_la_taburi(client):
|
||||
assert "?tab=cont" in html, \
|
||||
"Ghidul nu contine link catre tab-ul Cont (?tab=cont)"
|
||||
|
||||
# Ghidul trebuie sa contina link catre tab-ul Import
|
||||
assert "?tab=import" in html, \
|
||||
"Ghidul nu contine link catre tab-ul Import (?tab=import)"
|
||||
# Importul e acum direct pe Acasa (caseta de upload), nu un link catre alt tab
|
||||
assert 'hx-post="/_import/upload"' in html, \
|
||||
"Acasa trebuie sa contina caseta de upload (importul e operatia principala)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -227,13 +219,13 @@ def test_empty_state_coada_gol(client):
|
||||
assert "POST /v1/prezentari" not in html, \
|
||||
"Empty state coada nu trebuie sa contina mesajul tehnic vechi 'POST /v1/prezentari'"
|
||||
|
||||
# Trebuie sa contina un indemn catre Import
|
||||
# Trebuie sa contina un indemn catre Import (acum pe Acasa)
|
||||
assert "import" in html.lower() or "Import" in html, \
|
||||
"Empty state coada trebuie sa contina indemn catre Import"
|
||||
|
||||
# Trebuie sa contina link catre ?tab=import
|
||||
assert "?tab=import" in html, \
|
||||
"Empty state coada trebuie sa contina link ?tab=import"
|
||||
# Trebuie sa contina link catre Acasa (unde traieste importul acum)
|
||||
assert "?tab=acasa" in html, \
|
||||
"Empty state coada trebuie sa contina link catre Acasa (importul e acolo)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
88
tests/test_web_preview_motive.py
Normal file
88
tests/test_web_preview_motive.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Teste US-008 (PRD 3.5): preview-ul de import arata MOTIVUL randurilor respinse.
|
||||
|
||||
Un rand needs_data (ex. lipsa odometru) trebuie sa apara cu motivul explicit
|
||||
(mesajul de validare), nu doar numarat la "blocate".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prev.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _seed_mapping_op1() -> None:
|
||||
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1) ca randurile sa nu fie needs_mapping."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_preview_arata_motiv_needs_data(client):
|
||||
"""Un rand fara odometru apare in preview cu motivul, nu doar numarat la blocate."""
|
||||
_seed_mapping_op1()
|
||||
rows = [
|
||||
# rand valid
|
||||
{"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "123456", "Operatie": "OP-1"},
|
||||
# rand fara odometru -> needs_data
|
||||
{"VIN": "WVWZZZ1KZAW000456", "Nr inmatriculare": "B002TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "", "Operatie": "OP-1"},
|
||||
]
|
||||
data = _csv_bytes(rows)
|
||||
|
||||
# Upload -> formular mapare
|
||||
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
|
||||
assert r.status_code == 200
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||
assert m, "Nu am gasit import_id in formularul de mapare"
|
||||
import_id = int(m.group(1))
|
||||
|
||||
# Salveaza maparea -> preview
|
||||
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
|
||||
# Trebuie sa apara starea needs_data si MOTIVUL (mesajul de validare odometru)
|
||||
assert "needs_data" in html, "Randul fara odometru trebuia marcat needs_data"
|
||||
assert "odometruFinal" in html, (
|
||||
"Preview-ul nu arata motivul (mesajul de validare) pentru randul fara odometru"
|
||||
)
|
||||
129
tests/test_web_status.py
Normal file
129
tests/test_web_status.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Teste US-001 (PRD 3.5): bara de status compacta cu bife accesibile + data formatata.
|
||||
|
||||
Bifa = glifa distincta (✓ / ✗) + text, NU doar culoare (daltonism, design review).
|
||||
Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 Bife", active=True)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
return acct_id, user_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> None:
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = 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, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _set_heartbeat(last_beat: str | None, last_rar_login_ok: str | None) -> None:
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE worker_heartbeat SET last_beat=?, last_rar_login_ok=? WHERE id=1",
|
||||
(last_beat, last_rar_login_ok),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bife_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_status_are_bife_verzi_cand_totul_ok(client):
|
||||
"""Worker viu + RAR login recent -> bifa verde ✓ pentru ambele stari binare."""
|
||||
_create_account_user("bifeok@test.com")
|
||||
_login(client, "bifeok@test.com", "parolasecreta10")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Glifa de OK prezenta (accesibilitate: nu doar culoare)
|
||||
assert "✓" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
|
||||
# Texte umane de OK
|
||||
assert "activa" in html.lower()
|
||||
assert "functionala" in html.lower()
|
||||
|
||||
|
||||
def test_status_are_bife_rosii_cand_worker_oprit(client):
|
||||
"""Fara heartbeat -> worker oprit -> bifa rosie ✗ + text 'oprita'."""
|
||||
_create_account_user("biferosu@test.com")
|
||||
_login(client, "biferosu@test.com", "parolasecreta10")
|
||||
|
||||
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "✗" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}"
|
||||
assert "oprita" in html.lower()
|
||||
|
||||
|
||||
def test_status_data_formatata_romaneste(client):
|
||||
"""Ultima autentificare RAR apare ca dd.mm.yyyy hh24:mi:ss."""
|
||||
_create_account_user("bifedata@test.com")
|
||||
_login(client, "bifedata@test.com", "parolasecreta10")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_set_heartbeat(last_beat=now, last_rar_login_ok="2026-06-18T14:30:22")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
assert "18.06.2026 14:30:22" in resp.text, (
|
||||
f"Data nu e formatata romaneste. HTML: {resp.text[:800]}"
|
||||
)
|
||||
|
||||
|
||||
def test_status_fara_fonturi_minuscule(client):
|
||||
"""Niciun text din bara nu mai foloseste font-size sub 13px (US-001 AC)."""
|
||||
_create_account_user("bifefont@test.com")
|
||||
_login(client, "bifefont@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"):
|
||||
assert bad not in html, f"Bara de status foloseste {bad} (sub 13px)."
|
||||
150
tests/test_web_submissions.py
Normal file
150
tests/test_web_submissions.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Teste US-004 (PRD 3.5): "Coada" -> "Trimiteri" tabel lizibil + detaliu la click.
|
||||
|
||||
Coloane umane (RO), stare via labels (nu "sent" brut), vehicul/operatie/data din
|
||||
payload, motiv uman. Detaliu scoped pe cont (404 cross-account).
|
||||
"""
|
||||
|
||||
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_submission(acct: int, status: str = "sent", *, payload: dict | None = None,
|
||||
rar_error: str | None = None, id_prezentare=None) -> int:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
p = payload if payload is not None else {
|
||||
"vin": "WVWZZZ1JZXW000777",
|
||||
"nr_inmatriculare": "B777ZZZ",
|
||||
"data_prestatie": "2026-06-18",
|
||||
"odometru_final": "55000",
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
|
||||
}
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, id_prezentare) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p), rar_error, id_prezentare),
|
||||
)
|
||||
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, "subm.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_submissions_coloane_umane(client):
|
||||
"""Antete RO; stare umana (nu 'sent'); vehicul/operatie din payload; fara 'HTTP RAR' ca antet."""
|
||||
acct = _create_account_user("col@test.com")
|
||||
_insert_submission(acct, "sent", id_prezentare=68516)
|
||||
_login(client, "col@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Antete romanesti
|
||||
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR", "Motiv"):
|
||||
assert antet in html, f"Lipseste antetul '{antet}'"
|
||||
# "HTTP RAR" NU mai e antet principal de coloana
|
||||
assert "<th>HTTP RAR</th>" not in html
|
||||
# Starea afisata e text uman, nu 'sent' brut intr-un pill
|
||||
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata"
|
||||
assert "Declarate la RAR" in html, "Starea umana lipseste"
|
||||
# Vehicul + operatie din payload, nu doar idPrezentare
|
||||
assert "B777ZZZ" in html
|
||||
assert "Reparatie frane" in html
|
||||
|
||||
|
||||
def test_tab_eticheta_trimiteri(client):
|
||||
"""Eticheta tab e 'Trimiteri' dar deep-link ?tab=coada ramane valid."""
|
||||
_create_account_user("et@test.com")
|
||||
_login(client, "et@test.com")
|
||||
resp = client.get("/?tab=coada")
|
||||
assert resp.status_code == 200
|
||||
assert "Trimiteri" in resp.text
|
||||
assert 'id="tab-coada"' in resp.text
|
||||
|
||||
|
||||
def test_motiv_needs_data_afisat(client):
|
||||
"""Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error)."""
|
||||
acct = _create_account_user("motiv@test.com")
|
||||
_insert_submission(
|
||||
acct, "needs_data",
|
||||
rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru"}]),
|
||||
)
|
||||
_login(client, "motiv@test.com")
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
assert "lipsa odometru" in resp.text
|
||||
|
||||
|
||||
def test_detaliu_trimitere(client):
|
||||
"""/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont."""
|
||||
acct = _create_account_user("det@test.com")
|
||||
sid = _insert_submission(acct, "sent", id_prezentare=99001)
|
||||
_login(client, "det@test.com")
|
||||
|
||||
resp = client.get(f"/_fragments/trimitere/{sid}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert f"Detaliu trimitere #{sid}" in html
|
||||
assert "WVWZZZ1JZXW000777" in html # VIN integral in detaliu
|
||||
assert "99001" in html # nr prezentare RAR
|
||||
|
||||
|
||||
def test_detaliu_trimitere_404_cross_account(client):
|
||||
"""Detaliul altui cont -> 404 (fara leak)."""
|
||||
acct1 = _create_account_user("d1@test.com", name="C1")
|
||||
_create_account_user("d2@test.com", name="C2")
|
||||
sid1 = _insert_submission(acct1, "sent")
|
||||
|
||||
_login(client, "d2@test.com")
|
||||
resp = client.get(f"/_fragments/trimitere/{sid1}")
|
||||
assert resp.status_code == 404
|
||||
# acelasi 404 pentru un id inexistent
|
||||
resp2 = client.get("/_fragments/trimitere/999999")
|
||||
assert resp2.status_code == 404
|
||||
@@ -83,9 +83,12 @@ def test_dashboard_are_tabbar(client):
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
|
||||
# Cele 6 tab-uri trebuie sa fie prezente
|
||||
for label in ("Acasa", "Import", "Coada", "Mapari", "Cont", "Nomenclator"):
|
||||
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
|
||||
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
|
||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
||||
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
|
||||
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
|
||||
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user