feat(5.20): US-004/005/006/009 ingestie+API+worker+import pe mediu RAR
US-004: rezolva_rar_env (cerere>default cont>ancora globala) + MediuIndisponibil + cod RAR_MEDIU_INDISPONIBIL. US-005: camp rar_env pe POST /v1/prezentari + /valideaza (Literal), echo in SubmissionResult/ValidareResult/GET, build_key + INSERT env-aware. US-006: AccountSessions re-cheiat (account_id, rar_env); RarClient base_url per env; creds din slotul env; purge + recover_orphans scoped pe env (E1/1a, 1b/E6); claim_one propaga rar_env (1c/E8); keepalive pe ancora globala (M2). US-009: selector mediu la import (>=2 medii), eticheta la 1, banner la 0; commit seteaza rar_env pe submissions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,10 +89,24 @@ from ..mapping import (
|
||||
text_rules_overlap,
|
||||
)
|
||||
from ..shared_store import record_human_validation
|
||||
from ..rar_env import MediuIndisponibil, medii_disponibile_cont, rar_env_efectiv_cont, rezolva_rar_env
|
||||
|
||||
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
||||
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
||||
|
||||
|
||||
def _import_env_ctx(conn, account_id: int) -> dict:
|
||||
"""Contextul de mediu RAR pentru paginile de import (US-009, PRD 5.20).
|
||||
|
||||
Intoarce {'medii': list[str], 'env_default': str} pentru template-ul _upload.html.
|
||||
Un mediu e disponibil = activat SI are credentiale. La 0 medii template afiseaza
|
||||
un banner non-blocant; la 1 eticheta statica; la >=2 selector.
|
||||
"""
|
||||
medii = medii_disponibile_cont(conn, account_id)
|
||||
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
|
||||
return {"medii": medii, "env_default": env_default}
|
||||
|
||||
|
||||
router = APIRouter(tags=["web"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
# Expune parse_erori in toate template-urile
|
||||
@@ -738,8 +752,13 @@ def fragment_acasa(request: Request) -> HTMLResponse:
|
||||
@router.get("/_fragments/import", response_class=HTMLResponse)
|
||||
def fragment_import(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
||||
require_login(request)
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request))
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx))
|
||||
|
||||
|
||||
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
||||
@@ -2684,13 +2703,20 @@ def _web_compute_preview(
|
||||
conn,
|
||||
import_id: int,
|
||||
account_id: int,
|
||||
rar_env: str | None = None,
|
||||
) -> 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.
|
||||
|
||||
`rar_env`: mediul RAR ales de operator; None = default efectiv al contului
|
||||
(sau ancora globala daca contul nu are medii configurate). Folosit la calculul
|
||||
cheii de idempotenta la preview — trebuie sa coincida cu env-ul de la commit.
|
||||
"""
|
||||
acct = account_or_default(account_id)
|
||||
# Mediul folosit la calculul cheii de idempotenta (preview == commit).
|
||||
preview_env = rar_env or rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
||||
|
||||
batch = conn.execute(
|
||||
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
|
||||
@@ -2796,7 +2822,7 @@ def _web_compute_preview(
|
||||
key: str | None = None
|
||||
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||
try:
|
||||
key = _build_idempotency_key(account_id, info["resolved"])
|
||||
key = _build_idempotency_key(account_id, info["resolved"], preview_env)
|
||||
keys_for_lookup.append(key)
|
||||
key_to_indices.setdefault(key, []).append(i)
|
||||
except Exception:
|
||||
@@ -2901,6 +2927,7 @@ async def web_upload_import(
|
||||
file: UploadFile = File(...),
|
||||
sheet_name: str | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
rar_env: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
||||
|
||||
@@ -2919,31 +2946,64 @@ async def web_upload_import(
|
||||
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))
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names, **env_ctx))
|
||||
except FileTooLarge as e:
|
||||
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e))
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=str(e), eroare_upload=eroare_upload
|
||||
request, error=str(e), eroare_upload=eroare_upload, **env_ctx
|
||||
))
|
||||
except HeaderError as e:
|
||||
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}")
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload
|
||||
request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload, **env_ctx
|
||||
))
|
||||
except UnicodeDecodeError as e:
|
||||
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload
|
||||
request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload, **env_ctx
|
||||
))
|
||||
except Exception as e:
|
||||
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload
|
||||
request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload, **env_ctx
|
||||
))
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
sig = _signature(parsed.columns)
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
|
||||
# Rezolva mediul RAR ales — cerut din form sau default cont (fallback ancora globala).
|
||||
# La 0 medii: rezolva_rar_env cade pe ancora globala (rar_env config), non-blocant.
|
||||
try:
|
||||
upload_env = rezolva_rar_env(conn, account_id, rar_env or None)
|
||||
except (ValueError, MediuIndisponibil):
|
||||
upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
||||
|
||||
# Stagingul in DB (tranzactie explicita)
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
@@ -2978,18 +3038,17 @@ async def web_upload_import(
|
||||
|
||||
if existing:
|
||||
# Mapare retinuta → computa preview imediat
|
||||
result = _web_compute_preview(conn, batch_id_int, account_id)
|
||||
result = _web_compute_preview(conn, batch_id_int, account_id, rar_env=upload_env)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error=result, **env_ctx
|
||||
))
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": batch_id_int,
|
||||
"message": "Mapare retinuta aplicata automat.",
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"rar_env": upload_env,
|
||||
**result,
|
||||
})
|
||||
|
||||
@@ -3012,6 +3071,7 @@ async def web_upload_import(
|
||||
"canonical_fields": _CANONICAL_FIELDS,
|
||||
"format_data": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"rar_env": upload_env,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -3129,8 +3189,9 @@ async def web_save_mapare_coloane(
|
||||
(import_id, acct),
|
||||
).fetchone()
|
||||
if not batch:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error="Batch de import inexistent sau expirat."
|
||||
request, error="Batch de import inexistent sau expirat.", **env_ctx
|
||||
))
|
||||
|
||||
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
|
||||
@@ -3148,12 +3209,20 @@ async def web_save_mapare_coloane(
|
||||
(acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val),
|
||||
)
|
||||
|
||||
# Mediu RAR transmis din form-ul de mapare (daca exista) sau default cont
|
||||
form_rar_env = str(form.get("rar_env") or "").strip() or None
|
||||
try:
|
||||
mapare_env = rezolva_rar_env(conn, account_id, form_rar_env)
|
||||
except (ValueError, MediuIndisponibil):
|
||||
mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
||||
|
||||
# Computa preview
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
result = _web_compute_preview(conn, import_id, account_id, rar_env=mapare_env)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result, **env_ctx))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, **result
|
||||
request, import_id=import_id, rar_env=mapare_env, **result
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -3163,22 +3232,31 @@ async def web_save_mapare_coloane(
|
||||
def web_preview_import(
|
||||
request: Request,
|
||||
import_id: int,
|
||||
rar_env: str | None = None,
|
||||
) -> 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)
|
||||
# Rezolva mediul pentru preview (din query param sau default cont)
|
||||
try:
|
||||
preview_env = rezolva_rar_env(conn, account_id, rar_env)
|
||||
except (ValueError, MediuIndisponibil):
|
||||
preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
||||
result = _web_compute_preview(conn, import_id, account_id, rar_env=preview_env)
|
||||
if isinstance(result, str):
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
**env_ctx,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"rar_env": preview_env,
|
||||
**result,
|
||||
})
|
||||
finally:
|
||||
@@ -3601,10 +3679,13 @@ async def web_mapare_operatii(
|
||||
@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),
|
||||
})
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
env_ctx = _import_env_ctx(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, **env_ctx))
|
||||
|
||||
|
||||
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
||||
@@ -3631,6 +3712,9 @@ async def web_confirma_import(
|
||||
except (ValueError, TypeError):
|
||||
n_confirmat = 0
|
||||
|
||||
# Mediu RAR din form (selectat in preview); None = default cont (fallback ancora globala)
|
||||
rar_env_cerut = str(form.get("rar_env") or "").strip() or None
|
||||
|
||||
# US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul
|
||||
# de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8).
|
||||
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
|
||||
@@ -3798,6 +3882,12 @@ async def web_confirma_import(
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, acct)
|
||||
|
||||
# Rezolva mediul RAR tinta al lotului (US-009): form > default cont > ancora globala.
|
||||
try:
|
||||
env = rezolva_rar_env(conn, account_id, rar_env_cerut)
|
||||
except (ValueError, MediuIndisponibil):
|
||||
env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
|
||||
|
||||
# Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||
enqueued: list[dict] = []
|
||||
toctou: list[int] = []
|
||||
@@ -3857,7 +3947,7 @@ async def web_confirma_import(
|
||||
"odometru_final": canon["odometru_final"],
|
||||
})
|
||||
|
||||
key = build_key(account_id, canon)
|
||||
key = build_key(account_id, canon, env)
|
||||
|
||||
rows_for_hash.append(json.dumps({
|
||||
"row_index": row_index,
|
||||
@@ -3872,9 +3962,9 @@ async def web_confirma_import(
|
||||
|
||||
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),
|
||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
||||
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'), ?)",
|
||||
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index, env),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
toctou.append(row_index)
|
||||
@@ -3922,8 +4012,9 @@ async def web_confirma_import(
|
||||
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
|
||||
|
||||
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
|
||||
env_ctx_success = _import_env_ctx(conn, account_id)
|
||||
upload_html = templates.get_template("_upload.html").render(
|
||||
_ctx(request, are_trimiteri=True, message=succes_msg)
|
||||
_ctx(request, are_trimiteri=True, message=succes_msg, **env_ctx_success)
|
||||
)
|
||||
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
|
||||
status_html = templates.get_template("_status.html").render(status_ctx)
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
Preview —
|
||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||
</h2>
|
||||
{# Badge mediu RAR (US-009): vizibil intotdeauna in preview (claritate tinta) #}
|
||||
{% if rar_env %}
|
||||
<span class="pill" style="font-size:var(--fs-xs);
|
||||
{% if rar_env == 'prod' %}background:color-mix(in srgb,#B4452F 15%,var(--card)); border-color:#B4452F; color:#B4452F; font-weight:700;
|
||||
{% else %}background:color-mix(in srgb,var(--accent) 12%,var(--card)); border-color:var(--accent); color:var(--accent);{% endif %}">
|
||||
{{ "PRODUCTIE" if rar_env == "prod" else "Testare" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="muted" style="margin-left:auto; font-size:var(--fs-sm);">{{ total }} randuri</span>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +186,8 @@
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
{# Mediu RAR ales la upload — propagar la commit (US-009) #}
|
||||
<input type="hidden" name="rar_env" value="{{ rar_env or '' }}">
|
||||
<div class="sticky-bar">
|
||||
<div style="flex:1; min-width:280px;">
|
||||
<!-- Banner declarant — direct deasupra input-ului N -->
|
||||
|
||||
@@ -32,6 +32,38 @@
|
||||
hx-indicator="#upload-spinner">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
{# Indicator mediu RAR (US-009, PRD 5.20): vizibil inainte de drop-zone #}
|
||||
{% set medii_rar = medii | default([]) %}
|
||||
{% if medii_rar | length == 0 %}
|
||||
{# Banner avertisment — non-blocant (upload continua; commit foloseste ancora globala) #}
|
||||
<div style="margin-bottom:10px; padding:8px 14px; border-radius:6px;
|
||||
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
|
||||
border:1px solid var(--warn, #e6b34a); font-size:13px;" role="note">
|
||||
<strong>Niciun mediu RAR configurat.</strong>
|
||||
Trimiterea va folosi configuratia globala. Pentru a activa Testare sau Productie,
|
||||
<a href="?tab=cont" style="color:var(--accent);">configureaza credentialele RAR</a>.
|
||||
</div>
|
||||
{% elif medii_rar | length == 1 %}
|
||||
{# Eticheta statica (un singur mediu disponibil) #}
|
||||
<input type="hidden" name="rar_env" value="{{ medii_rar[0] }}">
|
||||
<div style="margin-bottom:10px; font-size:var(--fs-sm); color:var(--muted);">
|
||||
Mediu RAR:
|
||||
<strong>{{ "Testare" if medii_rar[0] == "test" else "Productie" }}</strong>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Selector (doua medii disponibile) #}
|
||||
<div style="margin-bottom:10px; display:flex; align-items:center; gap:10px;">
|
||||
<label for="rar-env-select"
|
||||
style="font-size:var(--fs-sm); color:var(--muted); white-space:nowrap;">
|
||||
Mediu RAR:
|
||||
</label>
|
||||
<select id="rar-env-select" name="rar_env">
|
||||
<option value="test" {% if env_default == "test" %}selected{% endif %}>Testare</option>
|
||||
<option value="prod" {% if env_default == "prod" %}selected{% endif %}>Productie</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sheets %}
|
||||
<div style="margin-bottom:12px;">
|
||||
<label for="sheet-select"
|
||||
|
||||
Reference in New Issue
Block a user