Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.
- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
`auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
defensiva idempotenta derivata din `active`, gate worker claim_one pe
status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.
Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.
VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).
Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2158 lines
85 KiB
Python
2158 lines
85 KiB
Python
"""Dashboard Jinja2 + HTMX (server-rendered, zero build).
|
|
|
|
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
|
|
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator +
|
|
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
|
|
|
|
U5 — Rute web pentru import fisier (upload → mapare coloane → preview → confirma → trimite).
|
|
Consuma endpointurile backend din import_router (helper-e interne) fara a le modifica.
|
|
Toate rutele /_import/* returneaza fragmente HTML targetate pe #import-section prin HTMX.
|
|
"""
|
|
|
|
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, HTTPException, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from .. import __version__
|
|
from .. import errors as _errors
|
|
from ..auth import rotate_api_key
|
|
from ..payload_view import prezentare_din_payload
|
|
from ..web.csrf import get_csrf_token, verify_csrf
|
|
from .labels import (
|
|
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
|
eticheta_rar,
|
|
eticheta_stare,
|
|
eticheta_worker,
|
|
format_data_rar,
|
|
motiv_uman,
|
|
parse_erori,
|
|
)
|
|
from ..web.session import require_login
|
|
from ..api.v1.import_router import (
|
|
_already_sent_lookup,
|
|
_build_idempotency_key,
|
|
_CANONICAL_SYNONYMS,
|
|
_fuzzy_suggest_column,
|
|
_resolve_row_for_preview,
|
|
_signature,
|
|
apply_row_override,
|
|
EDIT_FIELDS,
|
|
)
|
|
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,
|
|
reresolve_account,
|
|
resolve_prestatii,
|
|
save_mapping,
|
|
suggest_codes,
|
|
)
|
|
|
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane (U5)
|
|
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
|
|
|
router = APIRouter(tags=["web"])
|
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
|
# Expune parse_erori in toate template-urile (US-006, PRD 5.4)
|
|
templates.env.globals["parse_erori"] = parse_erori
|
|
|
|
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
|
|
|
|
|
def _ctx(request: Request, **extra) -> dict:
|
|
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
|
|
|
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
|
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
|
|
"""
|
|
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
|
|
|
|
|
def _status_counts(conn, account_id: int) -> dict[str, int]:
|
|
rows = conn.execute(
|
|
"SELECT status, COUNT(*) AS n FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
|
"GROUP BY status",
|
|
(account_id, account_id),
|
|
).fetchall()
|
|
return {r["status"]: int(r["n"]) for r in rows}
|
|
|
|
|
|
def _account_active(conn, account_id: int) -> bool:
|
|
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
|
|
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
return bool(row["active"]) if row else True
|
|
|
|
|
|
def _worker_alive(hb) -> bool:
|
|
if hb is None or not hb["last_beat"]:
|
|
return False
|
|
try:
|
|
last = datetime.fromisoformat(hb["last_beat"])
|
|
except ValueError:
|
|
return False
|
|
age = (datetime.now(timezone.utc) - last).total_seconds()
|
|
return age <= get_settings().worker_heartbeat_stale_s
|
|
|
|
|
|
def _rar_state(hb, worker_alive: bool) -> str:
|
|
"""Eticheta de disponibilitate RAR, derivata din ultimul login reusit.
|
|
|
|
Nu interogam RAR live aici (dashboard-ul degradeaza la ultima stare cunoscuta
|
|
a cozii). JWT TTL = 30h: un login mai vechi de atat inseamna ca nu mai stim
|
|
sigur ca RAR raspunde -> "indisponibil?". Fara niciun login -> necunoscut.
|
|
"""
|
|
if not worker_alive:
|
|
return "necunoscut (worker oprit)"
|
|
last = hb["last_rar_login_ok"] if hb else None
|
|
if not last:
|
|
return "fara login reusit inca"
|
|
try:
|
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
|
|
except (ValueError, TypeError):
|
|
return "necunoscut"
|
|
return "indisponibil?" if age > 108000 else "ok"
|
|
|
|
|
|
# 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.
|
|
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa.
|
|
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
|
|
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare"}
|
|
|
|
|
|
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|
"""Calculeaza contextul pentru panoul Acasa (US-005).
|
|
|
|
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
|
"""
|
|
from ..mapping import account_or_default
|
|
acct = account_or_default(account_id)
|
|
|
|
# Pas 1: are credentiale RAR configurate?
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
|
|
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
|
row_sub = conn.execute(
|
|
"SELECT 1 FROM submissions "
|
|
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
|
|
(acct, acct),
|
|
).fetchone()
|
|
are_trimiteri = row_sub is not None
|
|
|
|
# Pas 2 (optional): are cheie API activa?
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
|
|
(acct,),
|
|
).fetchone()
|
|
are_cheie_folosita = row_key is not None
|
|
|
|
# US-003 (3.6): contorul de atentie (blocate) se reflecta in heading-ul
|
|
# sectiunii "Trimiterile tale" de pe Acasa, nu pe un tab disparut.
|
|
counts = _status_counts(conn, account_id)
|
|
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
|
|
|
return {
|
|
"request": request,
|
|
"are_creds": are_creds,
|
|
"are_trimiteri": are_trimiteri,
|
|
"are_cheie_folosita": are_cheie_folosita,
|
|
"blocate_total": blocate_total,
|
|
# 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, "csrf_token": get_csrf_token(request)}
|
|
)
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.get_template("_acasa.html").render(ctx)
|
|
|
|
|
|
def _render_panel_import(request: Request) -> str:
|
|
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
|
|
return templates.get_template("_upload.html").render({
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
def _render_panel_coada(request: Request, conn=None, account_id: int = 1) -> str:
|
|
"""US-003 (3.6): "coada" nu mai e panou propriu — serveste continutul Acasa
|
|
(Trimiterile sunt sectiune pe Acasa). Pastrat ca alias pentru deep-link/bookmark vechi."""
|
|
return _render_panel_acasa(request, conn, account_id)
|
|
|
|
|
|
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
|
"""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),
|
|
})
|
|
|
|
|
|
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Cont ca string HTML."""
|
|
from ..mapping import account_or_default
|
|
acct = account_or_default(account_id)
|
|
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return templates.get_template("_cont.html").render({
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
"api_key": None,
|
|
"are_creds": are_creds,
|
|
"creds_mesaj": None,
|
|
"creds_eroare": None,
|
|
"rot_eroare": None,
|
|
})
|
|
|
|
|
|
def _render_panel_nomenclator(request: Request, conn) -> str:
|
|
"""Randeaza panoul Nomenclator ca string HTML."""
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return templates.get_template("_nomenclator.html").render({
|
|
"request": request,
|
|
"rows": rows,
|
|
})
|
|
|
|
|
|
def _render_integrare(request: Request, conn, account_id: int) -> str:
|
|
"""Randeaza panoul Integrare ca string HTML (hub documentatie + exemple cod).
|
|
|
|
Calculeaza are_cheie (chei API active pe cont) si are_creds (credentiale RAR
|
|
configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj.
|
|
"""
|
|
from ..mapping import account_or_default
|
|
from .integrare_examples import exemple as _exemple
|
|
|
|
acct = account_or_default(account_id)
|
|
row_creds = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row_creds and row_creds["rar_creds_enc"])
|
|
|
|
row_key = conn.execute(
|
|
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,)
|
|
).fetchone()
|
|
are_cheie = row_key is not None
|
|
|
|
base_url = str(request.base_url).rstrip("/")
|
|
ex = _exemple(base_url, acct)
|
|
csrf_token = get_csrf_token(request)
|
|
|
|
return templates.get_template("_integrare.html").render({
|
|
"request": request,
|
|
"account_id": acct,
|
|
"base_url": base_url,
|
|
"exemple": ex,
|
|
"are_cheie": are_cheie,
|
|
"are_creds": are_creds,
|
|
"csrf_token": csrf_token,
|
|
})
|
|
|
|
|
|
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
|
|
"""Randeaza panoul corespunzator unui tab ca string HTML."""
|
|
if tab == "acasa":
|
|
return _render_panel_acasa(request, conn, account_id)
|
|
if tab == "import":
|
|
return _render_panel_import(request)
|
|
if tab == "coada":
|
|
return _render_panel_coada(request, conn, account_id)
|
|
if tab == "mapari":
|
|
return _render_panel_mapari(request, conn, account_id)
|
|
if tab == "cont":
|
|
return _render_panel_cont(request, conn, account_id)
|
|
if tab == "nomenclator":
|
|
return _render_panel_nomenclator(request, conn)
|
|
if tab == "integrare":
|
|
return _render_integrare(request, conn, account_id)
|
|
return _render_panel_acasa(request)
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
|
"""Dashboard principal cu tab-uri (US-003).
|
|
|
|
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
|
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
|
Tab invalid -> fallback la 'acasa'.
|
|
"""
|
|
account_id = require_login(request)
|
|
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
|
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. Blocatele
|
|
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
|
|
counts = _status_counts(conn, account_id)
|
|
badges = {
|
|
"mapari": counts.get("needs_mapping", 0),
|
|
}
|
|
ctx = {
|
|
"request": request,
|
|
"rar_env": get_settings().rar_env,
|
|
"version": __version__,
|
|
"active_tab": active_tab,
|
|
"panel_html": panel_html,
|
|
"badges": badges,
|
|
"is_authenticated": True,
|
|
"is_admin": is_account_admin(conn, account_id),
|
|
"csrf_token": get_csrf_token(request),
|
|
}
|
|
return templates.TemplateResponse("dashboard.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
|
def fragment_acasa(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/import", response_class=HTMLResponse)
|
|
def fragment_import(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003)."""
|
|
require_login(request)
|
|
return templates.TemplateResponse("_upload.html", _ctx(request))
|
|
|
|
|
|
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
|
def fragment_coada(request: Request) -> HTMLResponse:
|
|
"""US-003 (3.6): "coada" nu mai are fragment propriu. Serveste continutul Acasa
|
|
(Trimiterile sunt sectiune permanenta pe Acasa) — evita un fragment `_coada.html`
|
|
orfan din bookmark-uri/HTMX vechi. Nu da 404."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
ctx = _get_acasa_context(request, conn, account_id)
|
|
return templates.TemplateResponse("_acasa.html", ctx)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/nomenclator", response_class=HTMLResponse)
|
|
def fragment_nomenclator(request: Request) -> HTMLResponse:
|
|
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login)."""
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
|
).fetchall()
|
|
return templates.TemplateResponse(
|
|
"_nomenclator.html", {"request": request, "rows": rows}
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/integrare", response_class=HTMLResponse)
|
|
def fragment_integrare(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX pentru tab-ul Integrare (hub documentatie + exemple cod)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
html = _render_integrare(request, conn, account_id)
|
|
return HTMLResponse(content=html)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_fragments/banner", response_class=HTMLResponse)
|
|
def fragment_banner(request: Request) -> HTMLResponse:
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
counts = _status_counts(conn, account_id)
|
|
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
|
return templates.TemplateResponse("_banner.html", {
|
|
"request": request,
|
|
"blocked": blocked,
|
|
"account_active": _account_active(conn, account_id),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
|
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
|
|
|
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
|
|
Returneaza lista goala daca nu exista nicio stare blocata.
|
|
"""
|
|
rezultat = []
|
|
for status in ("needs_mapping", "needs_data", "error"):
|
|
n = counts.get(status, 0)
|
|
if n > 0:
|
|
rezultat.append((eticheta_stare(status), n))
|
|
return rezultat
|
|
|
|
|
|
@router.get("/_fragments/status", response_class=HTMLResponse)
|
|
def fragment_status(request: Request) -> HTMLResponse:
|
|
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
|
|
|
|
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
|
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
|
Logica in routes.py (nu in template) pentru testabilitate.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
counts = _status_counts(conn, account_id)
|
|
hb = read_heartbeat(conn)
|
|
worker_alive = _worker_alive(hb)
|
|
rar_state = _rar_state(hb, worker_alive)
|
|
|
|
# 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_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": 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),
|
|
})
|
|
finally:
|
|
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,
|
|
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"]),
|
|
"erori_3n": parse_erori(row["rar_error"]),
|
|
"retry_count": row["retry_count"],
|
|
"created_at": format_data_rar(row["created_at"]),
|
|
"updated_at": format_data_rar(row["updated_at"]),
|
|
"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:
|
|
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:
|
|
return templates.TemplateResponse(
|
|
"_mapari.html",
|
|
{
|
|
"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),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/_fragments/mapari", response_class=HTMLResponse)
|
|
def fragment_mapari(request: Request) -> HTMLResponse:
|
|
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
|
|
|
|
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
return _render_mapari(request, conn, account_id)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/mapari", response_class=HTMLResponse)
|
|
def post_mapare(
|
|
request: Request,
|
|
cod_op_service: str = Form(...),
|
|
cod_prestatie: str = Form(...),
|
|
csrf_token: str | None = Form(None),
|
|
auto_send: bool = Form(False),
|
|
) -> HTMLResponse:
|
|
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
|
|
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"Mapat {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()
|
|
|
|
|
|
# =========================================================================== #
|
|
# 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. #
|
|
# Toate rutele /_import/* returneaza fragmente HTML (target #import-section). #
|
|
# =========================================================================== #
|
|
|
|
def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict]) -> list[dict]:
|
|
"""Operatii distincte nemapate dintr-un preview de import (staging), cu sugestii fuzzy.
|
|
|
|
Echivalentul lui pending_unmapped() dar pe randuri de PREVIEW (import in staging,
|
|
inca neexistente ca submissions). Aduna doar prestatiile fara cod_prestatie
|
|
(cele cu auto_send=0 au deja cod -> nu apar aici). Sortare: cele mai blocate intai.
|
|
"""
|
|
agg: dict[str, dict[str, Any]] = {}
|
|
for row in preview_rows:
|
|
if row.get("resolved_status") != "needs_mapping":
|
|
continue
|
|
for item in (row.get("resolved", {}).get("prestatii") or []):
|
|
if not isinstance(item, dict) or item.get("cod_prestatie"):
|
|
continue
|
|
op = (item.get("cod_op_service") or "").strip()
|
|
if not op:
|
|
continue
|
|
entry = agg.setdefault(op, {"cod_op_service": op, "denumire": item.get("denumire"), "blocked": 0})
|
|
if not entry["denumire"] and item.get("denumire"):
|
|
entry["denumire"] = item.get("denumire")
|
|
entry["blocked"] += 1
|
|
out: list[dict] = []
|
|
for entry in agg.values():
|
|
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
|
out.append(entry)
|
|
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
|
return out
|
|
|
|
|
|
def _web_compute_preview(
|
|
conn,
|
|
import_id: int,
|
|
account_id: int,
|
|
) -> dict[str, Any] | str:
|
|
"""Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare.
|
|
|
|
Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature
|
|
din import_router. Nu repeta logica de rezolvare — only orchestrare.
|
|
"""
|
|
acct = account_or_default(account_id)
|
|
|
|
batch = conn.execute(
|
|
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return "Batch de import inexistent sau inaccesibil."
|
|
|
|
raw_rows_db = conn.execute(
|
|
"SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index",
|
|
(import_id,),
|
|
).fetchall()
|
|
if not raw_rows_db:
|
|
return "Niciun rand in batch."
|
|
|
|
# Decripteaza randurile + override-urile editate (3.6)
|
|
rows: list[dict[str, Any]] = []
|
|
overrides: list[dict[str, Any]] = []
|
|
for r in raw_rows_db:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
except Exception:
|
|
row_data = {}
|
|
rows.append(row_data)
|
|
try:
|
|
ov = decrypt_creds(r["override_json"]) if r["override_json"] else None
|
|
except Exception:
|
|
ov = None
|
|
overrides.append(ov or {})
|
|
|
|
col_names = list(rows[0].keys()) if rows else []
|
|
sig = _signature(col_names)
|
|
|
|
mapping_row = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
if not mapping_row:
|
|
return "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea."
|
|
|
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"])
|
|
format_data: str | None = mapping_row["format_data"]
|
|
|
|
# Mapare operatii (o singura incarcare — Eng#5)
|
|
mapping_meta = load_mapping_meta(conn, acct)
|
|
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
|
|
|
# Detectie coercion flags din valorile stocate (VIN numeric)
|
|
coercion_flags_map: dict[int, list[str]] = {}
|
|
for i, row_dict in enumerate(rows):
|
|
flags: list[str] = []
|
|
for col_f, camp_c in json_mapare.items():
|
|
if camp_c == "vin":
|
|
vin_val = row_dict.get(col_f)
|
|
if vin_val is not None and str(vin_val).replace(".", "").isdigit():
|
|
flags.append(f"VIN numeric ({vin_val}) — verificati seria sasiului")
|
|
if flags:
|
|
coercion_flags_map[i] = flags
|
|
|
|
# Reconstructie date_col_format din format_data stocat in mapare
|
|
date_col_format: dict[str, str] = {}
|
|
if format_data:
|
|
for col_f, camp_c in json_mapare.items():
|
|
if camp_c == "data_prestatie":
|
|
date_col_format[col_f] = format_data
|
|
|
|
# Detectie coloane cu formule (rata None ridicata)
|
|
n_rows = len(rows)
|
|
formula_columns: list[str] = []
|
|
if n_rows > 0:
|
|
none_counts = {col_f: sum(1 for r in rows if r.get(col_f) is None) for col_f in col_names}
|
|
formula_columns = [col_f for col_f, cnt in none_counts.items() if cnt / n_rows >= 0.6]
|
|
|
|
# Rezolvare per rand
|
|
preview_rows: list[dict[str, Any]] = []
|
|
keys_for_lookup: list[str] = []
|
|
key_to_indices: dict[str, list[int]] = {}
|
|
|
|
for i, row_dict in enumerate(rows):
|
|
flags_i = coercion_flags_map.get(i, [])
|
|
info = _resolve_row_for_preview(
|
|
raw_row=row_dict,
|
|
json_mapare=json_mapare,
|
|
date_col_format=date_col_format,
|
|
coercion_flags=flags_i,
|
|
mapping=mapping,
|
|
mapping_meta=mapping_meta,
|
|
formula_columns=formula_columns,
|
|
override=overrides[i] or None,
|
|
)
|
|
|
|
key: str | None = None
|
|
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
try:
|
|
key = _build_idempotency_key(account_id, info["resolved"])
|
|
keys_for_lookup.append(key)
|
|
key_to_indices.setdefault(key, []).append(i)
|
|
except Exception:
|
|
pass
|
|
|
|
preview_rows.append({
|
|
"row_index": i,
|
|
"resolved_status": info["resolved_status"],
|
|
"resolved": info["resolved"],
|
|
"errors": info["errors"],
|
|
"flags": info["flags"],
|
|
"idempotency_key": key,
|
|
})
|
|
|
|
# Already_sent: batch lookup (Eng#5 — fara N+1)
|
|
unique_keys = list(set(keys_for_lookup))
|
|
already_sent_map = _already_sent_lookup(conn, account_id, unique_keys)
|
|
|
|
# Aplica already_sent si duplicate_in_file
|
|
for row in preview_rows:
|
|
k = row.get("idempotency_key")
|
|
if not k:
|
|
continue
|
|
if k in already_sent_map and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
row["resolved_status"] = "already_sent"
|
|
row["already_sent_info"] = already_sent_map[k]
|
|
continue
|
|
indices_same_key = key_to_indices.get(k, [])
|
|
if len(indices_same_key) > 1 and row["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
|
row["resolved_status"] = "duplicate_in_file"
|
|
row["duplicate_with"] = [idx for idx in indices_same_key if idx != row["row_index"]]
|
|
|
|
# Rezumat stari
|
|
summary: dict[str, int] = {}
|
|
for row in preview_rows:
|
|
s = row["resolved_status"]
|
|
summary[s] = summary.get(s, 0) + 1
|
|
|
|
# Actualizeaza contoare in import_batches
|
|
conn.execute(
|
|
"UPDATE import_batches SET ok=?, needs_mapping=?, needs_data=?, needs_review=?, "
|
|
"already_sent=?, duplicate_in_file=? WHERE id=?",
|
|
(
|
|
summary.get("ok", 0),
|
|
summary.get("needs_mapping", 0),
|
|
summary.get("needs_data", 0),
|
|
summary.get("needs_review", 0),
|
|
summary.get("already_sent", 0),
|
|
summary.get("duplicate_in_file", 0),
|
|
import_id,
|
|
),
|
|
)
|
|
|
|
# Actualizeaza resolved_status in import_rows
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
conn.executemany(
|
|
"UPDATE import_rows SET resolved_status=? WHERE batch_id=? AND row_index=?",
|
|
[(row["resolved_status"], import_id, row["row_index"]) for row in preview_rows],
|
|
)
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
|
|
nomenclator = load_nomenclator(conn)
|
|
return {
|
|
"rows": preview_rows,
|
|
"summary": summary,
|
|
"total": len(preview_rows),
|
|
"filename": batch["filename"],
|
|
"unmapped_ops": _collect_unmapped_ops(preview_rows, nomenclator),
|
|
"nomenclator": nomenclator,
|
|
}
|
|
|
|
|
|
@router.post("/_import/upload", response_class=HTMLResponse)
|
|
async def web_upload_import(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
sheet_name: str | None = Form(None),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
|
|
|
Daca maparea de coloane exista deja (signature match): computa preview imediat.
|
|
Daca nu: intoarce formularul de mapare coloane.
|
|
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
data = await file.read()
|
|
filename = file.filename or "fisier"
|
|
|
|
# Parsare fisier
|
|
try:
|
|
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
|
except MultipleSheets as ms:
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
|
except FileTooLarge as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=str(e), eroare_upload=eroare_upload
|
|
))
|
|
except HeaderError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
|
|
))
|
|
except UnicodeDecodeError as e:
|
|
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
|
|
))
|
|
except Exception as e:
|
|
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
|
|
))
|
|
|
|
conn = get_connection()
|
|
try:
|
|
sig = _signature(parsed.columns)
|
|
|
|
# Stagingul in DB (tranzactie explicita — Issue 6)
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
cur = conn.execute(
|
|
"INSERT INTO import_batches (account_id, filename, status, total, purge_after) "
|
|
"VALUES (?, ?, 'staging', ?, datetime('now', '+90 days'))",
|
|
(acct, filename, len(parsed.rows)),
|
|
)
|
|
batch_id = cur.lastrowid
|
|
conn.executemany(
|
|
"INSERT INTO import_rows (batch_id, row_index, raw_json, resolved_status, error) "
|
|
"VALUES (?, ?, ?, 'pending', NULL)",
|
|
[
|
|
(batch_id, i, encrypt_creds(row_dict))
|
|
for i, row_dict in enumerate(parsed.rows)
|
|
],
|
|
)
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
|
|
# Verifica mapare existenta
|
|
existing = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings "
|
|
"WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
|
|
batch_id_int: int = cur.lastrowid or 0 # lastrowid este int dupa INSERT reusit
|
|
|
|
if existing:
|
|
# Mapare retinuta → computa preview imediat
|
|
result = _web_compute_preview(conn, batch_id_int, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"error": result,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"message": "Mapare retinuta aplicata automat.",
|
|
"csrf_token": get_csrf_token(request),
|
|
**result,
|
|
})
|
|
|
|
# Mapare noua — sugestii fuzzy si formular de mapare
|
|
fuzzy_suggestions: dict[str, list[dict]] = {}
|
|
for col in parsed.columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy_suggestions[col] = sugg
|
|
|
|
return templates.TemplateResponse("_mapcoloane.html", {
|
|
"request": request,
|
|
"import_id": batch_id_int,
|
|
"filename": filename,
|
|
"columns": parsed.columns,
|
|
"sample_rows": parsed.rows[:3],
|
|
"fuzzy_suggestions": fuzzy_suggestions,
|
|
"canonical_fields": _CANONICAL_FIELDS,
|
|
"format_data": None,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/mapare-coloane", response_class=HTMLResponse)
|
|
async def web_save_mapare_coloane(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
|
|
# Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON
|
|
content_type = request.headers.get("content-type", "")
|
|
if "application/json" in content_type:
|
|
body = await request.body()
|
|
try:
|
|
json.loads(body)
|
|
# JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
# Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form
|
|
eroare_mapare = _errors.eroare(
|
|
"COLOANE_FORMAT_JSON",
|
|
cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.",
|
|
)
|
|
conn = get_connection()
|
|
try:
|
|
first_row = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
columns: list[str] = []
|
|
if first_row:
|
|
try:
|
|
rd = decrypt_creds(first_row["raw_json"]) or {}
|
|
columns = list(rd.keys())
|
|
except Exception:
|
|
pass
|
|
fuzzy: dict[str, list[dict]] = {}
|
|
for col in columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy[col] = sugg
|
|
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
columns=columns,
|
|
sample_rows=[],
|
|
fuzzy_suggestions=fuzzy,
|
|
canonical_fields=_CANONICAL_FIELDS,
|
|
format_data=None,
|
|
eroare_mapare=eroare_mapare,
|
|
error=True,
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
form = await request.form()
|
|
|
|
# Colectare perechi coloana fisier → camp canonic din form
|
|
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
|
|
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
|
|
format_data_val = str(form.get("format_data") or "").strip() or None
|
|
|
|
# Construieste json_mapare (ignora campurile marcate ca "ignorate")
|
|
json_mapare: dict[str, str] = {}
|
|
for colname, canon in zip(colnames, canons):
|
|
if canon:
|
|
json_mapare[colname] = canon
|
|
|
|
if not json_mapare:
|
|
# Nici un camp mapat → re-arata formularul cu eroare
|
|
conn = get_connection()
|
|
try:
|
|
first_row = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
columns = []
|
|
if first_row:
|
|
try:
|
|
rd = decrypt_creds(first_row["raw_json"]) or {}
|
|
columns = list(rd.keys())
|
|
except Exception:
|
|
pass
|
|
fuzzy: dict[str, list[dict]] = {}
|
|
for col in columns:
|
|
sugg = _fuzzy_suggest_column(col, limit=3)
|
|
if sugg:
|
|
fuzzy[col] = sugg
|
|
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
columns=columns,
|
|
sample_rows=[],
|
|
fuzzy_suggestions=fuzzy,
|
|
canonical_fields=_CANONICAL_FIELDS,
|
|
format_data=format_data_val,
|
|
message="Mapeaza cel putin un camp canonic inainte de a continua.",
|
|
error=True,
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
conn = get_connection()
|
|
try:
|
|
# Verifica ca batch-ul apartine contului
|
|
batch = conn.execute(
|
|
"SELECT id FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error="Batch de import inexistent sau expirat."
|
|
))
|
|
|
|
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
|
|
# ignorate), nu doar campurile mapate. Altfel ignorarea unei coloane schimba
|
|
# semnatura si maparea nu mai e gasita la preview/re-upload (col_names = antet
|
|
# complet peste tot). `colnames` vine din form = toate coloanele randate.
|
|
sig = _signature(colnames or list(json_mapare.keys()))
|
|
|
|
# Salveaza maparea (upsert)
|
|
conn.execute(
|
|
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) "
|
|
"VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(account_id, signature_coloane) DO UPDATE SET "
|
|
"json_mapare=excluded.json_mapare, format_data=excluded.format_data",
|
|
(acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val),
|
|
)
|
|
|
|
# Computa preview
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, **result
|
|
))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/{import_id}/preview", response_class=HTMLResponse)
|
|
def web_preview_import(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"error": result,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
return templates.TemplateResponse("_preview_import.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"csrf_token": get_csrf_token(request),
|
|
**result,
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-002 (3.6) — Editare celule in preview: mod editare pe rand. #
|
|
# Swap pe rand (#preview-row-N) + OOB contoare, NU pe #import-section (D-3.1). #
|
|
# Status rederivat DOAR prin _resolve_row_for_preview (H2 — fara clasificator). #
|
|
# =========================================================================== #
|
|
|
|
def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
|
|
"""Recalculeaza preview-ul si extrage un singur rand.
|
|
|
|
Statusul e rederivat prin `_resolve_row_for_preview` (H2, fara clasificator duplicat),
|
|
iar `_web_compute_preview` persista `resolved_status` pentru toate randurile — astfel
|
|
confirmarea (commit) vede starea editata. Intoarce (result, row) sau (mesaj, None)."""
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return result, None
|
|
row = next((r for r in result["rows"] if r["row_index"] == row_index), None)
|
|
return result, row
|
|
|
|
|
|
def _render_preview_rand(
|
|
request: Request, *, import_id: int, row: dict, editing: bool,
|
|
include_oob: bool, summary: dict, message: str | None = None,
|
|
) -> HTMLResponse:
|
|
return templates.TemplateResponse("_preview_rand.html", {
|
|
"request": request,
|
|
"import_id": import_id,
|
|
"row": row,
|
|
"editing": editing,
|
|
"include_oob": include_oob,
|
|
"summary": summary,
|
|
"message": message,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
|
|
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu, D-3.3)."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=True,
|
|
include_oob=False, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
|
|
def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare."""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=False,
|
|
include_oob=True, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
|
|
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
|
"""Alias web (US-001/US-002): persista override (mutatie pura) + re-randeaza DOAR randul.
|
|
|
|
Statusul e rederivat prin `_resolve_row_for_preview` (H2). Swap pe rand + OOB
|
|
contoare (D-3.1). Daca raman erori de continut pe camp, randul ramane in editare
|
|
cu valorile pastrate si mesajul pe campul vinovat (D-2.1/D-2.2)."""
|
|
account_id = require_login(request)
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
fields: dict[str, str | None] = {
|
|
camp: (str(form.get(camp)) if form.get(camp) is not None else None)
|
|
for camp in EDIT_FIELDS
|
|
}
|
|
conn = get_connection()
|
|
try:
|
|
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
|
# pastreaza randul + valorile la 4xx/5xx).
|
|
apply_row_override(
|
|
conn, import_id=import_id, account_id=account_id,
|
|
row_index=row_index, fields=fields,
|
|
)
|
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
|
if row is None or isinstance(result, str):
|
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
|
field_errors = [
|
|
e for e in (row.get("errors") or [])
|
|
if isinstance(e, dict) and e.get("field")
|
|
]
|
|
if field_errors:
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=True,
|
|
include_oob=True, summary=result["summary"],
|
|
message="Mai sunt valori invalide — corecteaza campurile marcate.",
|
|
)
|
|
return _render_preview_rand(
|
|
request, import_id=import_id, row=row, editing=False,
|
|
include_oob=True, summary=result["summary"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/_import/{import_id}/mapare-operatie", response_class=HTMLResponse)
|
|
async def web_mapare_operatie(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Mapeaza o operatie nemapata din preview-ul de import la un cod RAR, in flux.
|
|
|
|
Salveaza maparea (persistenta, operations_mapping) si re-randeaza preview-ul:
|
|
_web_compute_preview recalculeaza cu noua mapare si re-scrie resolved_status in
|
|
import_rows, deci randurile afectate trec din needs_mapping in ok fara re-upload.
|
|
"""
|
|
account_id = require_login(request)
|
|
conn = get_connection()
|
|
try:
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
cod_op_service = str(form.get("cod_op_service") or "").strip()
|
|
cod_prestatie = str(form.get("cod_prestatie") or "").strip().upper()
|
|
auto_send = bool(form.get("auto_send"))
|
|
|
|
def _render(message: str | None = None, error: bool = False) -> HTMLResponse:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, message=message, error=error, **result
|
|
))
|
|
|
|
if not cod_op_service or not cod_prestatie:
|
|
return _render("Alege un cod RAR pentru operatie.", error=True)
|
|
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
|
|
).fetchone()
|
|
if not exists:
|
|
return _render(f"Cod RAR necunoscut: {cod_prestatie}", error=True)
|
|
|
|
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send)
|
|
return _render(f"Mapat {cod_op_service} -> {cod_prestatie}.")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/_import/reset", response_class=HTMLResponse)
|
|
def web_import_reset(request: Request) -> HTMLResponse:
|
|
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
|
return templates.TemplateResponse("_upload.html", {
|
|
"request": request,
|
|
"csrf_token": get_csrf_token(request),
|
|
})
|
|
|
|
|
|
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
|
async def web_confirma_import(
|
|
request: Request,
|
|
import_id: int,
|
|
) -> HTMLResponse:
|
|
"""Gate HARD confirmare + enqueue randuri ok + log atestare. Intoarce fragment HTML.
|
|
|
|
Replica logica din import_router.commit_import dar cu input din form HTML
|
|
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
|
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
|
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
|
|
"""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
|
|
form = await request.form()
|
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
|
|
|
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
|
|
try:
|
|
n_confirmat = int(str(form.get("n_confirmat") or "0"))
|
|
except (ValueError, TypeError):
|
|
n_confirmat = 0
|
|
|
|
# Randuri needs_review bifate explicit
|
|
reviewed_rows: set[int] = set()
|
|
for v in form.getlist("reviewed_rows"):
|
|
if isinstance(v, str):
|
|
try:
|
|
reviewed_rows.add(int(v))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
|
|
|
|
conn = get_connection()
|
|
try:
|
|
batch = conn.execute(
|
|
"SELECT id, filename, status FROM import_batches WHERE id=? AND account_id=?",
|
|
(import_id, acct),
|
|
).fetchone()
|
|
if not batch:
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, error="Batch de import inexistent sau expirat."
|
|
))
|
|
|
|
if batch["status"] == "committed":
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request, message="Acest batch a fost deja comis."
|
|
))
|
|
|
|
# Incarca randurile cu stare ok si needs_review
|
|
ok_rows_db = conn.execute(
|
|
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
|
|
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
|
|
(import_id,),
|
|
).fetchall()
|
|
|
|
def _override_of(r) -> dict:
|
|
return (decrypt_creds(r["override_json"]) if r["override_json"] else None) or {}
|
|
|
|
if not ok_rows_db:
|
|
# Re-arata preview cu eroare
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
message="Niciun rand ok de confirmat in acest batch.",
|
|
error=True,
|
|
**result,
|
|
))
|
|
|
|
# Decripteaza si construieste lista de randuri de trimis
|
|
to_enqueue: list[dict[str, Any]] = []
|
|
review_indices: set[int] = set()
|
|
|
|
for r in ok_rows_db:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
except Exception:
|
|
continue
|
|
if r["resolved_status"] == "ok":
|
|
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
|
|
"override": _override_of(r), "status": "ok"})
|
|
elif r["resolved_status"] == "needs_review":
|
|
review_indices.add(r["row_index"])
|
|
|
|
# Adauga randurile needs_review bifate explicit
|
|
for r in ok_rows_db:
|
|
if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows:
|
|
try:
|
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
|
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
|
|
"override": _override_of(r), "status": "needs_review"})
|
|
except Exception:
|
|
pass
|
|
|
|
n_total_ok = len(to_enqueue)
|
|
|
|
# Gate HARD: n_confirmat trebuie sa fie exact egal cu numarul de randuri de trimis
|
|
if n_confirmat != n_total_ok:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
msg = (
|
|
f"Numarul confirmat ({n_confirmat}) difera de randurile gata de trimis ({n_total_ok}). "
|
|
f"Verifica preview-ul si retasteaza numarul corect."
|
|
)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request, import_id=import_id, message=msg, error=True, **result
|
|
))
|
|
|
|
if n_total_ok == 0:
|
|
result = _web_compute_preview(conn, import_id, account_id)
|
|
if isinstance(result, str):
|
|
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
|
request,
|
|
import_id=import_id,
|
|
message="Niciun rand ok de confirmat.",
|
|
error=True,
|
|
**result,
|
|
))
|
|
|
|
# Incarca maparea de coloane pentru payload
|
|
first_row_db = conn.execute(
|
|
"SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1",
|
|
(import_id,),
|
|
).fetchone()
|
|
col_names: list[str] = []
|
|
if first_row_db:
|
|
try:
|
|
fd = decrypt_creds(first_row_db["raw_json"]) or {}
|
|
col_names = list(fd.keys())
|
|
except Exception:
|
|
pass
|
|
|
|
sig = _signature(col_names) if col_names else ""
|
|
mapping_row = conn.execute(
|
|
"SELECT json_mapare, format_data FROM column_mappings WHERE account_id=? AND signature_coloane=?",
|
|
(acct, sig),
|
|
).fetchone()
|
|
|
|
json_mapare: dict[str, str] = json.loads(mapping_row["json_mapare"]) if mapping_row else {}
|
|
fmt = mapping_row["format_data"] if mapping_row else None
|
|
|
|
# Mapare operatii
|
|
mapping_meta = load_mapping_meta(conn, acct)
|
|
mapping_ops = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
|
|
|
|
# Enqueue in tranzactie explicita (Issue 6) — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
|
enqueued: list[dict] = []
|
|
toctou: list[int] = []
|
|
rows_for_hash: list[str] = []
|
|
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
for item in to_enqueue:
|
|
row_dict = item["data"]
|
|
row_index = item["row_index"]
|
|
|
|
# Aplica maparea de coloane
|
|
mapped: dict[str, Any] = {}
|
|
for col_f, camp_c in json_mapare.items():
|
|
if col_f in row_dict and camp_c:
|
|
mapped[camp_c] = row_dict[col_f]
|
|
|
|
# Rezolva data
|
|
for col_f, camp_c in json_mapare.items():
|
|
if camp_c == "data_prestatie":
|
|
col_fmt = fmt or "ambiguous"
|
|
raw_date = mapped.get("data_prestatie")
|
|
if raw_date is not None:
|
|
iso_date, _ = parse_date_value(raw_date, col_fmt)
|
|
if iso_date:
|
|
mapped["data_prestatie"] = iso_date
|
|
break
|
|
|
|
# Operatia → prestatii (denumire_op alimenteaza denumirea reala)
|
|
operatie_val = mapped.pop("operatie", None)
|
|
denumire_val = mapped.pop("denumire_op", None)
|
|
if operatie_val and "prestatii" not in mapped:
|
|
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
|
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
|
|
|
# Rezolva prestatii
|
|
prestatii = mapped.get("prestatii") or []
|
|
resolved_p, _ = resolve_prestatii(prestatii, mapping_ops)
|
|
mapped["prestatii"] = resolved_p
|
|
|
|
# Canonicalizare
|
|
canon = canonicalize_row(mapped)
|
|
mapped.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
|
|
# Override editat in preview (3.6) — aplicat ULTIMUL, ca in resolver.
|
|
override = item.get("override") or {}
|
|
if override:
|
|
mapped.update(override)
|
|
canon = canonicalize_row(mapped)
|
|
mapped.update({
|
|
"vin": canon["vin"],
|
|
"nr_inmatriculare": canon["nr_inmatriculare"],
|
|
"odometru_final": canon["odometru_final"],
|
|
})
|
|
|
|
key = build_key(account_id, canon)
|
|
|
|
rows_for_hash.append(json.dumps({
|
|
"row_index": row_index,
|
|
"vin": mapped.get("vin"),
|
|
"data_prestatie": mapped.get("data_prestatie"),
|
|
"odometru_final": mapped.get("odometru_final"),
|
|
"prestatii": [
|
|
str(p.get("cod_prestatie") or p.get("cod_op_service") or "")
|
|
for p in resolved_p
|
|
],
|
|
}, sort_keys=True, ensure_ascii=False))
|
|
|
|
cur = conn.execute(
|
|
"INSERT OR IGNORE INTO submissions "
|
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
|
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'))",
|
|
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index),
|
|
)
|
|
if cur.rowcount == 0:
|
|
toctou.append(row_index)
|
|
else:
|
|
enqueued.append({"submission_id": cur.lastrowid, "row_index": row_index})
|
|
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
|
|
n_enqueued = len(enqueued)
|
|
|
|
# Log atestare (Voce#9)
|
|
rows_hash = hashlib.sha256(
|
|
json.dumps(sorted(rows_for_hash), ensure_ascii=False).encode("utf-8")
|
|
).hexdigest() if rows_for_hash else ""
|
|
|
|
conn.execute(
|
|
"INSERT INTO import_attestations (batch_id, account_id, confirmed_by, rows_hash, n_confirmed) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(import_id, acct, confirmed_by, rows_hash, n_enqueued),
|
|
)
|
|
conn.execute(
|
|
"UPDATE import_batches SET status='committed', ok=? WHERE id=?",
|
|
(n_enqueued, import_id),
|
|
)
|
|
|
|
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
|
|
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
|
|
# sectiunea "Trimiterile tale" de pe Acasa (US-003/US-004).
|
|
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
|
return templates.TemplateResponse("_upload.html", _ctx(
|
|
request,
|
|
are_trimiteri=True,
|
|
message=(
|
|
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
|
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
|
|
),
|
|
))
|
|
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# =========================================================================== #
|
|
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
|
|
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
|
|
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
|
# =========================================================================== #
|
|
|
|
def _render_cont(
|
|
request: Request,
|
|
*,
|
|
api_key: str | None = None,
|
|
are_creds: bool = False,
|
|
creds_mesaj: str | None = None,
|
|
creds_eroare: str | None = None,
|
|
rot_eroare: str | None = None,
|
|
) -> HTMLResponse:
|
|
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
|
return templates.TemplateResponse(
|
|
"_cont.html",
|
|
_ctx(
|
|
request,
|
|
api_key=api_key,
|
|
are_creds=are_creds,
|
|
creds_mesaj=creds_mesaj,
|
|
creds_eroare=creds_eroare,
|
|
rot_eroare=rot_eroare,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
|
def fragment_cont(request: Request) -> HTMLResponse:
|
|
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
|
|
account_id = require_login(request)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return _render_cont(request, are_creds=are_creds)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
|
|
def cont_roteste_cheie(
|
|
request: Request,
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
conn = get_connection()
|
|
try:
|
|
new_key = rotate_api_key(conn, acct)
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
return _render_cont(request, api_key=new_key, are_creds=are_creds)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.post("/integrare/test-cheie", response_class=HTMLResponse)
|
|
def integrare_test_cheie(
|
|
request: Request,
|
|
api_key: str = Form(""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Verifica cheia API lipita de utilizator — scoped pe contul sesiunii.
|
|
|
|
US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata
|
|
din generatorul de exemple corespunde contului sau, fara efecte secundare
|
|
(fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de
|
|
eroare neutru (fara eco al cheii in raspuns).
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
# Validare cheie goala / doar spatii -> eroare inainte de DB
|
|
cheie = (api_key or "").strip()
|
|
if not cheie:
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False, "mesaj": "Cheia este goala sau lipseste."},
|
|
)
|
|
|
|
conn = get_connection()
|
|
try:
|
|
from ..auth import account_for_key
|
|
cont_cheie = account_for_key(conn, cheie)
|
|
finally:
|
|
conn.close()
|
|
|
|
if cont_cheie is None:
|
|
# Cheie invalida, inexistenta sau revocata
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False,
|
|
"mesaj": "Cheie invalida sau revocata — nu a fost gasita in sistem."},
|
|
)
|
|
|
|
if cont_cheie != acct:
|
|
# Cheie valida dar apartine altui cont — mesaj neutru, fara dezvaluire cont terta
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": False,
|
|
"mesaj": "Cheia nu apartine contului tau."},
|
|
)
|
|
|
|
# Succes — cheia e activa si corespunde contului sesiunii
|
|
return templates.TemplateResponse(
|
|
"_integrare_test_rezultat.html",
|
|
{"request": request, "succes": True,
|
|
"mesaj": f"Cheie valida — cont {acct}."},
|
|
)
|
|
|
|
|
|
@router.post("/cont/rar-creds", response_class=HTMLResponse)
|
|
def cont_rar_creds(
|
|
request: Request,
|
|
rar_email: str = Form(""),
|
|
rar_parola: str = Form(""),
|
|
csrf_token: str | None = Form(None),
|
|
) -> HTMLResponse:
|
|
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
|
|
|
|
Camp parola NICIODATA re-pus in value= la re-randare.
|
|
Validare minima: email si parola negoale.
|
|
"""
|
|
account_id = require_login(request)
|
|
verify_csrf(request, csrf_token)
|
|
acct = account_or_default(account_id)
|
|
|
|
email = rar_email.strip()
|
|
parola = rar_parola.strip()
|
|
|
|
if not email or not parola:
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
).fetchone()
|
|
are_creds = bool(row and row["rar_creds_enc"])
|
|
finally:
|
|
conn.close()
|
|
return _render_cont(
|
|
request,
|
|
are_creds=are_creds,
|
|
creds_eroare="Email si parola sunt obligatorii.",
|
|
)
|
|
|
|
enc = encrypt_creds({"email": email, "password": parola})
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
|
(enc, acct),
|
|
)
|
|
return _render_cont(
|
|
request,
|
|
are_creds=True,
|
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
|
)
|
|
finally:
|
|
conn.close()
|