Compare commits
9 Commits
851f76ca16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eaf1ca6cd | ||
|
|
1648960b13 | ||
|
|
3579a15363 | ||
|
|
19d8aaa7aa | ||
|
|
d5ce0e2e2b | ||
|
|
deb6afff3e | ||
|
|
b4818349be | ||
|
|
ff9d0f41d1 | ||
|
|
7371c3703d |
@@ -55,6 +55,7 @@ from ...mapping import (
|
|||||||
resolve_prestatii,
|
resolve_prestatii,
|
||||||
)
|
)
|
||||||
from ...validation import validate_prezentare
|
from ...validation import validate_prezentare
|
||||||
|
from ...rar_env import MediuIndisponibil, rar_env_efectiv_cont, rezolva_rar_env
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/import", tags=["import"])
|
router = APIRouter(prefix="/v1/import", tags=["import"])
|
||||||
|
|
||||||
@@ -260,10 +261,10 @@ def _resolve_row_for_preview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) -> str:
|
def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any], rar_env: str = "test") -> str:
|
||||||
"""Construieste cheia de idempotenta pentru un rand rezolvat."""
|
"""Construieste cheia de idempotenta pentru un rand rezolvat."""
|
||||||
canon = canonicalize_row(resolved)
|
canon = canonicalize_row(resolved)
|
||||||
return build_key(account_id, canon)
|
return build_key(account_id, canon, rar_env)
|
||||||
|
|
||||||
|
|
||||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||||
@@ -767,6 +768,11 @@ def preview_import(
|
|||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
|
|
||||||
|
# Mediul RAR efectiv al contului — folosit la calculul cheii de idempotenta
|
||||||
|
# la preview (trebuie sa coincida cu ce va folosi commit-ul fara rar_env explicit).
|
||||||
|
from ...config import get_settings as _get_settings_env
|
||||||
|
preview_env = rar_env_efectiv_cont(conn, account_id) or _get_settings_env().rar_env or "test"
|
||||||
|
|
||||||
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
# Recalculam coercion_flags din valorile stocate (nu sunt persistate separat):
|
||||||
# detectie simpla de VIN numeric.
|
# detectie simpla de VIN numeric.
|
||||||
coercion_flags_map: dict[int, list[str]] = {}
|
coercion_flags_map: dict[int, list[str]] = {}
|
||||||
@@ -822,7 +828,7 @@ def preview_import(
|
|||||||
key = None
|
key = None
|
||||||
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
if resolved_info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||||
try:
|
try:
|
||||||
key = _build_idempotency_key(account_id, resolved_info["resolved"])
|
key = _build_idempotency_key(account_id, resolved_info["resolved"], preview_env)
|
||||||
keys_for_lookup.append(key)
|
keys_for_lookup.append(key)
|
||||||
if key not in key_to_index:
|
if key not in key_to_index:
|
||||||
key_to_index[key] = []
|
key_to_index[key] = []
|
||||||
@@ -930,6 +936,7 @@ class CommitIn(BaseModel):
|
|||||||
description="Indecsi de rand needs_review bifate explicit de utilizator",
|
description="Indecsi de rand needs_review bifate explicit de utilizator",
|
||||||
)
|
)
|
||||||
confirmed_by: str | None = Field(None, description="Email/identifier utilizator (log atestare)")
|
confirmed_by: str | None = Field(None, description="Email/identifier utilizator (log atestare)")
|
||||||
|
rar_env: str | None = Field(None, description="Mediu RAR tinta ('test'|'prod'). None = default cont.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{import_id}/commit")
|
@router.post("/{import_id}/commit")
|
||||||
@@ -1024,6 +1031,18 @@ def commit_import(
|
|||||||
if n_total_ok == 0:
|
if n_total_ok == 0:
|
||||||
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
raise HTTPException(status_code=422, detail="Niciun rand ok de confirmat.")
|
||||||
|
|
||||||
|
# Rezolva mediul RAR tinta al lotului (US-009): cerut > default cont > ancora globala.
|
||||||
|
try:
|
||||||
|
env = rezolva_rar_env(conn, account_id, req.rar_env)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail={"error": "mediu_invalid", "message": str(e)})
|
||||||
|
except MediuIndisponibil as e:
|
||||||
|
raise HTTPException(status_code=422, detail={
|
||||||
|
"error": "mediu_indisponibil",
|
||||||
|
"message": str(e),
|
||||||
|
"disponibile": e.disponibile,
|
||||||
|
})
|
||||||
|
|
||||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
# T3 (PRD 5.17): enforce volum plan — INAINTE de enqueue (invariant idempotenta).
|
||||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||||
from ...config import get_settings as _get_settings
|
from ...config import get_settings as _get_settings
|
||||||
@@ -1172,8 +1191,8 @@ def commit_import(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine)
|
# Cheia de idempotenta (identica cu cheia din preview — aceeasi ordine + env)
|
||||||
key = build_key(account_id, canon)
|
key = build_key(account_id, canon, env)
|
||||||
|
|
||||||
# Hash row pentru atestare (valori rezolvate)
|
# Hash row pentru atestare (valori rezolvate)
|
||||||
rows_for_hash.append(json.dumps({
|
rows_for_hash.append(json.dumps({
|
||||||
@@ -1189,9 +1208,9 @@ def commit_import(
|
|||||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT OR IGNORE INTO submissions "
|
"INSERT OR IGNORE INTO submissions "
|
||||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
||||||
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ")",
|
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
|
||||||
(key, acct, payload_json, import_id, row_index),
|
(key, acct, payload_json, import_id, row_index, env),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ...crypto import encrypt_creds
|
|||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
from ...errors import eroare as err_eroare
|
from ...errors import eroare as err_eroare
|
||||||
from ...idempotency import build_key, canonicalize_row
|
from ...idempotency import build_key, canonicalize_row
|
||||||
|
from ...rar_env import MediuIndisponibil, rezolva_rar_env
|
||||||
from ...mapping import (
|
from ...mapping import (
|
||||||
_emite_text_rule_hits,
|
_emite_text_rule_hits,
|
||||||
account_or_default,
|
account_or_default,
|
||||||
@@ -122,7 +123,7 @@ def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> Submissio
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
def _rezultat_respins(submission_id: int | None, cl: dict, rar_env: str = "test") -> SubmissionResult:
|
||||||
"""Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare.
|
"""Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare.
|
||||||
|
|
||||||
`erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate.
|
`erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate.
|
||||||
@@ -131,6 +132,7 @@ def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult:
|
|||||||
return SubmissionResult(
|
return SubmissionResult(
|
||||||
submission_id=submission_id, status="error",
|
submission_id=submission_id, status="error",
|
||||||
erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl),
|
erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl),
|
||||||
|
rar_env=rar_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +170,29 @@ def create_prezentari(
|
|||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||||
|
|
||||||
|
# US-005: rezolva mediul RAR tinta (cerut > default cont > ancora globala).
|
||||||
|
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
|
||||||
|
try:
|
||||||
|
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||||
|
except MediuIndisponibil as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=err_eroare(
|
||||||
|
"RAR_MEDIU_INDISPONIBIL",
|
||||||
|
cauza=(
|
||||||
|
f"mediu cerut: {e.env}; disponibile: "
|
||||||
|
f"{', '.join(e.disponibile) or 'niciunul'}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Pydantic Literal prinde valorile invalide inainte sa ajunga aici;
|
||||||
|
# ramura e defensiva pentru apeluri directe fara model Pydantic.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
||||||
|
)
|
||||||
|
|
||||||
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
# T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
|
||||||
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
# Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut).
|
||||||
from ...config import get_settings as _get_settings
|
from ...config import get_settings as _get_settings
|
||||||
@@ -213,7 +238,7 @@ def create_prezentari(
|
|||||||
# build_key aplica account_or_default(account_id) inainte de hash:
|
# build_key aplica account_or_default(account_id) inainte de hash:
|
||||||
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
# None si 1 colapseaza la aceeasi cheie (canal API + canal import).
|
||||||
canon = canonicalize_row(content)
|
canon = canonicalize_row(content)
|
||||||
key = build_key(account_id, canon)
|
key = build_key(account_id, canon, env)
|
||||||
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
|
||||||
content.update({
|
content.update({
|
||||||
"vin": canon["vin"],
|
"vin": canon["vin"],
|
||||||
@@ -232,19 +257,20 @@ def create_prezentari(
|
|||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||||
results.append(_rezultat_respins(existing["id"], cl))
|
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||||
continue
|
continue
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
||||||
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
||||||
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
||||||
"updated_at=datetime('now') WHERE id=? AND status='error'",
|
"rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
|
||||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
|
||||||
cl["rar_error"], creds_enc, existing["id"]),
|
cl["rar_error"], creds_enc, env, existing["id"]),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 1:
|
if cur.rowcount == 1:
|
||||||
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc)
|
||||||
# — ambele canale converg pe parola corectata.
|
# — ambele canale converg pe parola corectata.
|
||||||
|
# US-013: muta pe slot env dupa login (write-back conservator).
|
||||||
if req.rar_credentials is not None:
|
if req.rar_credentials is not None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||||
@@ -253,7 +279,7 @@ def create_prezentari(
|
|||||||
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
# needs_data/needs_mapping, expune motivul (nu doar status).
|
||||||
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True))
|
results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True, rar_env=env))
|
||||||
continue
|
continue
|
||||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||||
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
||||||
@@ -267,6 +293,7 @@ def create_prezentari(
|
|||||||
status=existing["status"],
|
status=existing["status"],
|
||||||
id_prezentare=existing["id_prezentare"],
|
id_prezentare=existing["id_prezentare"],
|
||||||
deduped=True,
|
deduped=True,
|
||||||
|
rar_env=env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -276,17 +303,17 @@ def create_prezentari(
|
|||||||
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||||
if cl["blocked_error"]:
|
if cl["blocked_error"]:
|
||||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||||
results.append(_rezultat_respins(None, cl))
|
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||||
continue
|
continue
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
|
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
|
||||||
)
|
)
|
||||||
sub_id = int(cur.lastrowid)
|
sub_id = int(cur.lastrowid)
|
||||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
||||||
results.append(_rezultat_enqueue(sub_id, cl))
|
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
|
||||||
|
|
||||||
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
||||||
@@ -332,6 +359,27 @@ def valideaza_prezentari(
|
|||||||
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
|
||||||
text_rules = load_text_rules(conn, acct)
|
text_rules = load_text_rules(conn, acct)
|
||||||
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
|
||||||
|
|
||||||
|
# US-005 (DX F5): rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
|
||||||
|
try:
|
||||||
|
env = rezolva_rar_env(conn, acct, req.rar_env)
|
||||||
|
except MediuIndisponibil as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=err_eroare(
|
||||||
|
"RAR_MEDIU_INDISPONIBIL",
|
||||||
|
cauza=(
|
||||||
|
f"mediu cerut: {e.env}; disponibile: "
|
||||||
|
f"{', '.join(e.disponibile) or 'niciunul'}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
|
||||||
|
)
|
||||||
|
|
||||||
for i, prez in enumerate(req.prezentari):
|
for i, prez in enumerate(req.prezentari):
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules)
|
||||||
@@ -346,6 +394,7 @@ def valideaza_prezentari(
|
|||||||
index=i,
|
index=i,
|
||||||
valid=(res["status"] == "queued"),
|
valid=(res["status"] == "queued"),
|
||||||
status_estimat=res["status"],
|
status_estimat=res["status"],
|
||||||
|
rar_env=env,
|
||||||
erori=res["errors"],
|
erori=res["errors"],
|
||||||
nemapate=nemapate,
|
nemapate=nemapate,
|
||||||
prestatii_rezolvate=res["resolved"],
|
prestatii_rezolvate=res["resolved"],
|
||||||
@@ -366,9 +415,10 @@ def list_prezentari(
|
|||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
# payload_json e plaintext (vezi submissions.payload_json); il citim doar ca
|
||||||
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
# sa derivam campurile afisabile prin helper-ul partajat, nu il expunem.
|
||||||
|
# rar_env inclus (US-005): badge mediu in lista.
|
||||||
cols = (
|
cols = (
|
||||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
"id, status, id_prezentare, rar_status_code, retry_count, "
|
||||||
"created_at, updated_at, payload_json"
|
"created_at, updated_at, payload_json, rar_env"
|
||||||
)
|
)
|
||||||
if status:
|
if status:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -403,6 +453,8 @@ _PREZENTARE_FIELDS = frozenset({
|
|||||||
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
|
||||||
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
|
||||||
"rar_error",
|
"rar_error",
|
||||||
|
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
||||||
|
"rar_env",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
110
app/db.py
110
app/db.py
@@ -71,11 +71,26 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
conn.execute("ALTER TABLE submissions ADD COLUMN batch_id INTEGER")
|
||||||
if "row_index" not in sub_cols:
|
if "row_index" not in sub_cols:
|
||||||
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
conn.execute("ALTER TABLE submissions ADD COLUMN row_index INTEGER")
|
||||||
|
if "rar_env" not in sub_cols:
|
||||||
|
# PRD 5.20 US-001. Mediul RAR tinta pe submission. Pe DB existent NU lasam
|
||||||
|
# randurile pe DEFAULT 'test': un rand prod pre-migrare etichetat 'test' ar fi
|
||||||
|
# reconciliat de worker (US-006) contra endpoint TEST -> no-match -> re-send prod
|
||||||
|
# = DUPLICAT REAL IREVERSIBIL. Backfill din AUTOPASS_RAR_ENV global (ancora de
|
||||||
|
# migrare) + recompute idempotency_key env-aware. Ruleaza O SINGURA DATA (in
|
||||||
|
# blocul de adaugare a coloanei); pe DB fresh coloana vine din schema.sql (fara rows).
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE submissions ADD COLUMN rar_env TEXT NOT NULL DEFAULT 'test' "
|
||||||
|
"CHECK (rar_env IN ('test', 'prod'))"
|
||||||
|
)
|
||||||
|
_backfill_submissions_rar_env(conn)
|
||||||
|
|
||||||
# Coloane accounts
|
# Coloane accounts
|
||||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
if "rar_creds_enc" not in acc_cols:
|
if "rar_creds_enc" not in acc_cols:
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT")
|
||||||
|
acc_cols.add("rar_creds_enc")
|
||||||
|
# Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu.
|
||||||
|
_migrate_accounts_medii(conn, acc_cols)
|
||||||
if "active" not in acc_cols:
|
if "active" not in acc_cols:
|
||||||
# Conturi existente raman active (default 1).
|
# Conturi existente raman active (default 1).
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||||
@@ -164,6 +179,101 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:
|
||||||
|
"""PRD 5.20 US-001: coloane medii RAR per cont + backfill din ancora globala.
|
||||||
|
|
||||||
|
Adauga (idempotent): rar_test_enabled/rar_prod_enabled (bife activare),
|
||||||
|
rar_creds_test_enc/rar_creds_prod_enc (sloturi creds), rar_env_default.
|
||||||
|
|
||||||
|
Backfill (O SINGURA DATA, cand coloanele tocmai au fost adaugate pe DB existent):
|
||||||
|
creds-ul legacy `rar_creds_enc` apartine mediului `AUTOPASS_RAR_ENV` global de la
|
||||||
|
momentul migrarii (ancora) — il copiem in slotul acelui mediu, activam DOAR acel
|
||||||
|
mediu (celalalt dezactivat) si fixam default-ul pe el. Conturile fara creds raman
|
||||||
|
pe default-urile coloanei (prod on / test off). Migrarea NU presupune env-ul; se
|
||||||
|
bazeaza pe ancora globala, exact cum opera contul inainte de 5.20.
|
||||||
|
"""
|
||||||
|
newly_added = "rar_env_default" not in acc_cols
|
||||||
|
if "rar_test_enabled" not in acc_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN rar_test_enabled INTEGER NOT NULL DEFAULT 0 "
|
||||||
|
"CHECK (rar_test_enabled IN (0, 1))"
|
||||||
|
)
|
||||||
|
if "rar_prod_enabled" not in acc_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN rar_prod_enabled INTEGER NOT NULL DEFAULT 1 "
|
||||||
|
"CHECK (rar_prod_enabled IN (0, 1))"
|
||||||
|
)
|
||||||
|
if "rar_creds_test_enc" not in acc_cols:
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_test_enc TEXT")
|
||||||
|
if "rar_creds_prod_enc" not in acc_cols:
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_prod_enc TEXT")
|
||||||
|
if "rar_env_default" not in acc_cols:
|
||||||
|
# ALTER nu poate adauga CHECK pe coloana noua in SQLite -> validarea ('test'/'prod')
|
||||||
|
# se face in cod (rar_env.py / rutele de cont). DEFAULT 'prod' (cont client nou).
|
||||||
|
conn.execute("ALTER TABLE accounts ADD COLUMN rar_env_default TEXT NOT NULL DEFAULT 'prod'")
|
||||||
|
|
||||||
|
if not newly_added:
|
||||||
|
return # coloanele existau deja -> backfill-ul a rulat la o pornire anterioara
|
||||||
|
|
||||||
|
# Are coloana legacy rar_creds_enc randuri de migrat? (Pe DB foarte nou, e absenta.)
|
||||||
|
if "rar_creds_enc" not in acc_cols:
|
||||||
|
return
|
||||||
|
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||||
|
other = "prod" if env == "test" else "test"
|
||||||
|
slot = f"rar_creds_{env}_enc"
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE accounts SET {slot} = rar_creds_enc, "
|
||||||
|
f"rar_{env}_enabled = 1, rar_{other}_enabled = 0, rar_env_default = ? "
|
||||||
|
f"WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc) <> '' AND {slot} IS NULL",
|
||||||
|
(env,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None:
|
||||||
|
"""PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key.
|
||||||
|
|
||||||
|
Ruleaza O SINGURA DATA, imediat dupa ce coloana `submissions.rar_env` a fost adaugata
|
||||||
|
pe un DB existent. Toate randurile pre-migrare au fost trimise (sau urmeaza) catre
|
||||||
|
mediul `AUTOPASS_RAR_ENV` global — le etichetam cu acel env (NU DEFAULT 'test'), altfel
|
||||||
|
reconcilierea worker-ului ar lovi endpoint-ul gresit -> duplicat ireversibil.
|
||||||
|
|
||||||
|
Recompute `idempotency_key` la forma env-aware (`build_key(account_id, canon, rar_env)`):
|
||||||
|
altfel un re-POST al unui rand legacy (cheie env-less) ar rata randul existent ->
|
||||||
|
duplicat. Recompute-ul e consistent (acelasi env pe toate randurile pre-migrare) deci
|
||||||
|
nu poate crea coliziuni intre randuri care erau deja distincte.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from .idempotency import build_key, canonicalize_row
|
||||||
|
|
||||||
|
env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test"
|
||||||
|
conn.execute("UPDATE submissions SET rar_env = ?", (env,))
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, account_id, idempotency_key, payload_json FROM submissions"
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
content = _json.loads(r["payload_json"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
canon = canonicalize_row(content)
|
||||||
|
# Pastreaza prestatiile rezolvate (cod_prestatie/cod_op_service) pentru _op_identity.
|
||||||
|
canon["prestatii"] = content.get("prestatii") or []
|
||||||
|
new_key = build_key(r["account_id"], canon, env)
|
||||||
|
if new_key == r["idempotency_key"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET idempotency_key = ? WHERE id = ?",
|
||||||
|
(new_key, r["id"]),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Coliziune improbabila pe UNIQUE(idempotency_key): lasa cheia veche (no-op),
|
||||||
|
# randul ramane gasibil prin dual-lookup legacy.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,13 @@ CATALOG: dict[str, dict[str, str]] = {
|
|||||||
" Contacteaza-ne pentru a face upgrade la planul Pro."
|
" Contacteaza-ne pentru a face upgrade la planul Pro."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"RAR_MEDIU_INDISPONIBIL": {
|
||||||
|
"problema": "Mediul RAR cerut nu e disponibil",
|
||||||
|
"fix": (
|
||||||
|
"Activeaza mediul si introdu credentialele RAR in tab-ul Cont."
|
||||||
|
" Mediile disponibile acum sunt in campul cauza."
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,17 +70,23 @@ def canonicalize_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
def build_key(account_id: int | None, canon: dict[str, Any], rar_env: str = "test") -> str:
|
||||||
"""SHA-256 partajat canal-API + canal-import.
|
"""SHA-256 partajat canal-API + canal-import, env-aware (PRD 5.20 US-003).
|
||||||
|
|
||||||
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
Aplica account_or_default inainte de hash: None si 1 colapseaza la aceeasi
|
||||||
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
cheie => acelasi rand logic din canale diferite nu se trimite de doua ori.
|
||||||
|
|
||||||
|
`rar_env` ('test'|'prod') intra in cheie: aceeasi prezentare la test si apoi la
|
||||||
|
prod sunt DOUA trimiteri reale distincte (sisteme RAR separate), nu un duplicat.
|
||||||
|
Default 'test' = back-compat cu apelantii care nu paseaza inca env-ul; toate
|
||||||
|
rutele de ingestie paseaza env-ul rezolvat explicit.
|
||||||
"""
|
"""
|
||||||
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
# Import local ca sa evitam import circular (mapping importa din idempotency via validator)
|
||||||
from .mapping import account_or_default
|
from .mapping import account_or_default
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
canonic = {
|
canonic = {
|
||||||
"account_id": acct,
|
"account_id": acct,
|
||||||
|
"rar_env": rar_env,
|
||||||
"vin": canon.get("vin", ""),
|
"vin": canon.get("vin", ""),
|
||||||
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
"nr_inmatriculare": canon.get("nr_inmatriculare", ""),
|
||||||
"data_prestatie": canon.get("data_prestatie"),
|
"data_prestatie": canon.get("data_prestatie"),
|
||||||
@@ -91,8 +97,8 @@ def build_key(account_id: int | None, canon: dict[str, Any]) -> str:
|
|||||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
def idempotency_key(account_id: int | None, prezentare: dict[str, Any], rar_env: str = "test") -> str:
|
||||||
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
|
"""SHA-256 peste (account_id + rar_env + campurile semnificative ale prezentarii).
|
||||||
|
|
||||||
Wrapper backward-compat peste canonicalize_row + build_key.
|
Wrapper backward-compat peste canonicalize_row + build_key.
|
||||||
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
|
||||||
@@ -102,7 +108,7 @@ def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
|||||||
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
acoperite automat — dual-lookup sau recompute-keys la migrare productie.
|
||||||
"""
|
"""
|
||||||
canon = canonicalize_row(prezentare)
|
canon = canonicalize_row(prezentare)
|
||||||
return build_key(account_id, canon)
|
return build_key(account_id, canon, rar_env)
|
||||||
|
|
||||||
|
|
||||||
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
def build_key_legacy(account_id: int | None, prezentare: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ odometru) este in app.validation.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +95,8 @@ class PrezentareRequest(BaseModel):
|
|||||||
# False -> submission 'needs_mapping' (intra in editorul de mapare);
|
# False -> submission 'needs_mapping' (intra in editorul de mapare);
|
||||||
# None -> se foloseste accounts.on_unmapped_error_default (implicit False).
|
# None -> se foloseste accounts.on_unmapped_error_default (implicit False).
|
||||||
on_unmapped_error: bool | None = None
|
on_unmapped_error: bool | None = None
|
||||||
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT).
|
||||||
|
rar_env: Literal["test", "prod"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubmissionResult(BaseModel):
|
class SubmissionResult(BaseModel):
|
||||||
@@ -105,6 +109,8 @@ class SubmissionResult(BaseModel):
|
|||||||
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
||||||
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||||
reactivated: bool = False
|
reactivated: bool = False
|
||||||
|
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
|
||||||
|
rar_env: str = "test"
|
||||||
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
||||||
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
# motivul, ca integratorul sa nu trateze un needs_data/needs_mapping drept succes.
|
||||||
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
# erori = validare de continut (needs_data), 3 niveluri [{field, cod, problema, cauza, fix, message}].
|
||||||
@@ -126,6 +132,8 @@ class ValidarePrezentariRequest(BaseModel):
|
|||||||
rar_credentials: RarCredentials | None = None
|
rar_credentials: RarCredentials | None = None
|
||||||
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
prezentari: list[PrezentareIn] = Field(..., min_length=1)
|
||||||
on_unmapped_error: bool | None = None
|
on_unmapped_error: bool | None = None
|
||||||
|
# Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului.
|
||||||
|
rar_env: Literal["test", "prod"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ValidareResult(BaseModel):
|
class ValidareResult(BaseModel):
|
||||||
@@ -134,6 +142,7 @@ class ValidareResult(BaseModel):
|
|||||||
index: int
|
index: int
|
||||||
valid: bool
|
valid: bool
|
||||||
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
|
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
|
||||||
|
rar_env: str = "test" # mediul RAR tinta efectiv (ecou din rezolvare)
|
||||||
erori: list[dict] = []
|
erori: list[dict] = []
|
||||||
nemapate: list[dict] = []
|
nemapate: list[dict] = []
|
||||||
prestatii_rezolvate: list[dict] = []
|
prestatii_rezolvate: list[dict] = []
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class RarAuthError(RarError):
|
|||||||
"""Login esuat (401 / credentiale invalide). NU se face retry."""
|
"""Login esuat (401 / credentiale invalide). NU se face retry."""
|
||||||
|
|
||||||
|
|
||||||
|
def base_url_pentru_env(settings: "Settings", env: str) -> str:
|
||||||
|
"""URL de baza al mediului RAR: 'prod' -> rar_base_url_prod, altfel rar_base_url_test."""
|
||||||
|
return settings.rar_base_url_prod if env == "prod" else settings.rar_base_url_test
|
||||||
|
|
||||||
|
|
||||||
class RarClient:
|
class RarClient:
|
||||||
"""Client sincron httpx. Folosit din worker (proces separat).
|
"""Client sincron httpx. Folosit din worker (proces separat).
|
||||||
|
|
||||||
@@ -53,10 +58,10 @@ class RarClient:
|
|||||||
data = rar.post_prezentare(token, payload)
|
data = rar.post_prezentare(token, payload)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, settings: Settings | None = None):
|
def __init__(self, settings: Settings | None = None, *, base_url: str | None = None):
|
||||||
self.settings = settings or get_settings()
|
self.settings = settings or get_settings()
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
base_url=self.settings.rar_base_url,
|
base_url=base_url if base_url is not None else self.settings.rar_base_url,
|
||||||
timeout=self.settings.http_timeout_s,
|
timeout=self.settings.http_timeout_s,
|
||||||
headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403
|
headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403
|
||||||
)
|
)
|
||||||
|
|||||||
156
app/rar_env.py
Normal file
156
app/rar_env.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Medii RAR per cont (PRD 5.20): disponibilitate + default efectiv.
|
||||||
|
|
||||||
|
Sursa UNICA de adevar pentru REQ-DISP / REQ-DEFAULT: vizibilitatea selector/toggle
|
||||||
|
in UI, validarea tintei in API si decizia worker-ului citesc TOATE de aici, ca sa
|
||||||
|
decida identic.
|
||||||
|
|
||||||
|
Un mediu ('test'|'prod') e *disponibil* pentru un cont daca e activat (bifa) SI are
|
||||||
|
credentiale (slot per-mediu non-gol). Din disponibilitate decurge tot UX-ul:
|
||||||
|
- 0 medii -> nicio tinta; trimiterea web e blocata, API cade pe ancora globala.
|
||||||
|
- 1 mediu -> tinta implicita (acel mediu), fara selector.
|
||||||
|
- 2 medii -> selector la import + toggle in statusbar + alegere in API.
|
||||||
|
|
||||||
|
Functii PURE (fara DB) peste un rand de cont (sqlite3.Row sau dict). Helperele cu
|
||||||
|
`conn` incarca randul si deleaga.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VALID_ENVS: tuple[str, str] = ("test", "prod")
|
||||||
|
|
||||||
|
|
||||||
|
def _field(account: Any, key: str, default: Any = None) -> Any:
|
||||||
|
"""Citire toleranta a unui camp de cont (dict sau sqlite3.Row, camp posibil absent)."""
|
||||||
|
if account is None:
|
||||||
|
return default
|
||||||
|
if isinstance(account, dict):
|
||||||
|
return account.get(key, default)
|
||||||
|
try:
|
||||||
|
return account[key] # sqlite3.Row
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _are_creds(account: Any, env: str) -> bool:
|
||||||
|
creds = _field(account, f"rar_creds_{env}_enc", None)
|
||||||
|
return bool(creds and str(creds).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(account: Any, env: str) -> bool:
|
||||||
|
return int(_field(account, f"rar_{env}_enabled", 0) or 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def medii_disponibile(account: Any) -> list[str]:
|
||||||
|
"""Subset din ('test','prod') = activat AND creds prezente. Ordine stabila test<prod."""
|
||||||
|
return [env for env in VALID_ENVS if _enabled(account, env) and _are_creds(account, env)]
|
||||||
|
|
||||||
|
|
||||||
|
def rar_env_efectiv(account: Any) -> str | None:
|
||||||
|
"""Mediul tinta implicit al contului (REQ-DEFAULT).
|
||||||
|
|
||||||
|
Mereu unul din mediile disponibile: default-ul contului daca inca e disponibil,
|
||||||
|
altfel singurul disponibil; daca 0 disponibile -> None (nicio tinta).
|
||||||
|
"""
|
||||||
|
disp = medii_disponibile(account)
|
||||||
|
if not disp:
|
||||||
|
return None
|
||||||
|
default = _field(account, "rar_env_default", "prod")
|
||||||
|
if default in disp:
|
||||||
|
return default
|
||||||
|
return disp[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpere cu conexiune #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_ACCOUNT_ENV_COLS = (
|
||||||
|
"id, rar_test_enabled, rar_prod_enabled, "
|
||||||
|
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_account_env(conn: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
|
||||||
|
"""Randul de cont cu exact coloanele de mediu (pentru medii_disponibile/rar_env_efectiv)."""
|
||||||
|
from .mapping import account_or_default
|
||||||
|
|
||||||
|
return conn.execute(
|
||||||
|
f"SELECT {_ACCOUNT_ENV_COLS} FROM accounts WHERE id=?",
|
||||||
|
(account_or_default(account_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def medii_disponibile_cont(conn: sqlite3.Connection, account_id: int) -> list[str]:
|
||||||
|
return medii_disponibile(load_account_env(conn, account_id))
|
||||||
|
|
||||||
|
|
||||||
|
def rar_env_efectiv_cont(conn: sqlite3.Connection, account_id: int) -> str | None:
|
||||||
|
return rar_env_efectiv(load_account_env(conn, account_id))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Exceptie si rezolvator de mediu tinta (US-004, dependent de US-002) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class MediuIndisponibil(Exception):
|
||||||
|
"""Mediul RAR cerut e valid dar nu e disponibil pentru contul dat.
|
||||||
|
|
||||||
|
Atribute
|
||||||
|
--------
|
||||||
|
env: mediul cerut (ex. 'test')
|
||||||
|
disponibile: lista mediilor disponibile pentru cont in momentul erorii
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, env: str, disponibile: list[str]) -> None:
|
||||||
|
self.env = env
|
||||||
|
self.disponibile = disponibile
|
||||||
|
super().__init__(
|
||||||
|
f"mediu indisponibil: {env!r} (disponibile: {disponibile!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rezolva_rar_env(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
account_id: int,
|
||||||
|
cerut: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Determina mediul RAR tinta pentru un submission la ingestie.
|
||||||
|
|
||||||
|
Precedenta stricta (de la cea mai mare la cea mai mica):
|
||||||
|
1. `cerut` explicit si disponibil -> intoarce `cerut`.
|
||||||
|
2. `cerut` explicit dar indisponibil -> ridica MediuIndisponibil.
|
||||||
|
3. `cerut` invalid (nu in VALID_ENVS) -> ridica ValueError (fara fallback silentios).
|
||||||
|
4. `cerut` None -> incearca rar_env_efectiv_cont (default-ul contului).
|
||||||
|
5. Daca contul nu are niciun mediu disponibil (rar_env_efectiv_cont == None)
|
||||||
|
-> cade pe ancora globala get_settings().rar_env, normalizata la VALID_ENVS.
|
||||||
|
Acest fallback e intentionat (PRD 5.20 §2 Non-Goals): AUTOPASS_RAR_ENV ramane
|
||||||
|
ancora de migrare si fallback pentru actiuni fara cont (keepalive, canal API cu
|
||||||
|
creds efemere pe conturi nou-create fara medii configurate).
|
||||||
|
|
||||||
|
Ridica
|
||||||
|
------
|
||||||
|
ValueError -- `cerut` nu e in VALID_ENVS
|
||||||
|
MediuIndisponibil -- `cerut` e valid dar nu e disponibil pentru cont
|
||||||
|
"""
|
||||||
|
if cerut is not None:
|
||||||
|
if cerut not in VALID_ENVS:
|
||||||
|
raise ValueError(f"mediu invalid: {cerut!r}")
|
||||||
|
disp = medii_disponibile_cont(conn, account_id)
|
||||||
|
if cerut not in disp:
|
||||||
|
raise MediuIndisponibil(cerut, disp)
|
||||||
|
return cerut
|
||||||
|
|
||||||
|
# cerut e None: incearca default-ul contului
|
||||||
|
efectiv = rar_env_efectiv_cont(conn, account_id)
|
||||||
|
if efectiv is not None:
|
||||||
|
return efectiv
|
||||||
|
|
||||||
|
# Ancora globala: 0 medii disponibile pe cont -> fallback la AUTOPASS_RAR_ENV.
|
||||||
|
from .config import get_settings
|
||||||
|
global_env = get_settings().rar_env
|
||||||
|
if global_env in VALID_ENVS:
|
||||||
|
return global_env
|
||||||
|
return "test" # rar_env invalid in config -> cel mai sigur default
|
||||||
@@ -19,7 +19,15 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
-- vezi accounts.delete_account — randul ramane doar pentru audit).
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
CHECK (status IN ('pending','active','blocked','archived','deleted')),
|
||||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
|
rar_creds_enc TEXT, -- LEGACY (PRD 5.20 US-013 dropeaza coloana): creds RAR durabile env-less
|
||||||
|
-- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds.
|
||||||
|
-- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou =
|
||||||
|
-- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare.
|
||||||
|
rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)),
|
||||||
|
rar_prod_enabled INTEGER NOT NULL DEFAULT 1 CHECK (rar_prod_enabled IN (0, 1)),
|
||||||
|
rar_creds_test_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Testare
|
||||||
|
rar_creds_prod_enc TEXT, -- creds RAR criptate (Fernet) pentru mediul Productie
|
||||||
|
rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test', 'prod')),
|
||||||
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
|
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
|
||||||
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
|
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
|
||||||
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
||||||
@@ -88,6 +96,10 @@ CREATE TABLE IF NOT EXISTS submissions (
|
|||||||
status TEXT NOT NULL DEFAULT 'queued'
|
status TEXT NOT NULL DEFAULT 'queued'
|
||||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
|
-- Mediul RAR tinta al acestei trimiteri (PRD 5.20 US-001). DEFAULT 'test' e doar plasa
|
||||||
|
-- pentru randuri net-noi care nu seteaza explicit; fiecare INSERT (API/import/web) seteaza
|
||||||
|
-- rar_env explicit. Backfill din AUTOPASS_RAR_ENV global la migrare (NU lasa pe DEFAULT).
|
||||||
|
rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test', 'prod')),
|
||||||
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit
|
||||||
rar_status_code INTEGER,
|
rar_status_code INTEGER,
|
||||||
rar_error TEXT,
|
rar_error TEXT,
|
||||||
|
|||||||
@@ -89,10 +89,25 @@ from ..mapping import (
|
|||||||
text_rules_overlap,
|
text_rules_overlap,
|
||||||
)
|
)
|
||||||
from ..shared_store import record_human_validation
|
from ..shared_store import record_human_validation
|
||||||
|
from ..rar_env import MediuIndisponibil, medii_disponibile_cont, rar_env_efectiv_cont, rezolva_rar_env
|
||||||
|
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
|
||||||
|
|
||||||
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
# Campuri canonice cu eticheta umana pentru dropdown mapare coloane
|
||||||
_CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()]
|
_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"])
|
router = APIRouter(tags=["web"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
# Expune parse_erori in toate template-urile
|
# Expune parse_erori in toate template-urile
|
||||||
@@ -277,10 +292,14 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
|||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
|
|
||||||
# Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet)
|
# Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet)
|
||||||
|
# Verifica atat coloana legacy rar_creds_enc cat si sloturile per-env (US-008, PRD 5.20).
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
"SELECT id, name, cui, email, rar_creds_enc, rar_creds_test_enc, rar_creds_prod_enc "
|
||||||
|
"FROM accounts WHERE id=?", (acct,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
are_creds = bool(row and (
|
||||||
|
row["rar_creds_enc"] or row["rar_creds_test_enc"] or row["rar_creds_prod_enc"]
|
||||||
|
))
|
||||||
# Banner cont incomplet (US-002): contul nu are companie + email + CUI complete
|
# Banner cont incomplet (US-002): contul nu are companie + email + CUI complete
|
||||||
cont_incomplet = not _acct_is_complete(row) if row else False
|
cont_incomplet = not _acct_is_complete(row) if row else False
|
||||||
|
|
||||||
@@ -370,22 +389,25 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
|||||||
"""Randeaza panoul Cont ca string HTML."""
|
"""Randeaza panoul Cont ca string HTML."""
|
||||||
from ..mapping import account_or_default
|
from ..mapping import account_or_default
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
||||||
).fetchone()
|
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
cont_ctx = {
|
cont_ctx = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"are_creds": are_creds,
|
|
||||||
"creds_mesaj": None,
|
"creds_mesaj": None,
|
||||||
"creds_eroare": None,
|
"creds_eroare": None,
|
||||||
"rot_eroare": None,
|
"rot_eroare": None,
|
||||||
"account_meta": account_meta,
|
"account_meta": account_meta,
|
||||||
"date_firma_mesaj": None,
|
"date_firma_mesaj": None,
|
||||||
"date_firma_eroare": None,
|
"date_firma_eroare": None,
|
||||||
|
"creds_test_mesaj": None,
|
||||||
|
"creds_test_eroare": None,
|
||||||
|
"creds_prod_mesaj": None,
|
||||||
|
"creds_prod_eroare": None,
|
||||||
|
"creds_default_eroare": None,
|
||||||
|
"creds_default_mesaj": None,
|
||||||
|
**env_ctx,
|
||||||
}
|
}
|
||||||
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
|
# US-006 (5.17): context plan pentru sectiunea Plan din _cont.html.
|
||||||
cont_ctx.update(_plan_ctx(conn, account_id))
|
cont_ctx.update(_plan_ctx(conn, account_id))
|
||||||
@@ -738,8 +760,13 @@ def fragment_acasa(request: Request) -> HTMLResponse:
|
|||||||
@router.get("/_fragments/import", response_class=HTMLResponse)
|
@router.get("/_fragments/import", response_class=HTMLResponse)
|
||||||
def fragment_import(request: Request) -> HTMLResponse:
|
def fragment_import(request: Request) -> HTMLResponse:
|
||||||
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
"""Fragment HTMX pentru tab-ul Import — include zona de upload."""
|
||||||
require_login(request)
|
account_id = require_login(request)
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(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)
|
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
||||||
@@ -2684,13 +2711,20 @@ def _web_compute_preview(
|
|||||||
conn,
|
conn,
|
||||||
import_id: int,
|
import_id: int,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
rar_env: str | None = None,
|
||||||
) -> dict[str, Any] | str:
|
) -> dict[str, Any] | str:
|
||||||
"""Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare.
|
"""Calculeaza preview pentru un batch; intoarce date sau str cu mesaj de eroare.
|
||||||
|
|
||||||
Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature
|
Reutilizeaza _resolve_row_for_preview, _already_sent_lookup, _signature
|
||||||
din import_router. Nu repeta logica de rezolvare — only orchestrare.
|
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)
|
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(
|
batch = conn.execute(
|
||||||
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
|
"SELECT id, account_id, filename FROM import_batches WHERE id=? AND account_id=?",
|
||||||
@@ -2796,7 +2830,7 @@ def _web_compute_preview(
|
|||||||
key: str | None = None
|
key: str | None = None
|
||||||
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
if info["resolved_status"] in ("ok", "needs_review", "needs_data"):
|
||||||
try:
|
try:
|
||||||
key = _build_idempotency_key(account_id, info["resolved"])
|
key = _build_idempotency_key(account_id, info["resolved"], preview_env)
|
||||||
keys_for_lookup.append(key)
|
keys_for_lookup.append(key)
|
||||||
key_to_indices.setdefault(key, []).append(i)
|
key_to_indices.setdefault(key, []).append(i)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2901,6 +2935,7 @@ async def web_upload_import(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
sheet_name: str | None = Form(None),
|
sheet_name: str | None = Form(None),
|
||||||
csrf_token: str | None = Form(None),
|
csrf_token: str | None = Form(None),
|
||||||
|
rar_env: str | None = Form(None),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
||||||
|
|
||||||
@@ -2919,31 +2954,64 @@ async def web_upload_import(
|
|||||||
try:
|
try:
|
||||||
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
||||||
except MultipleSheets as ms:
|
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:
|
except FileTooLarge as e:
|
||||||
eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(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(
|
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:
|
except HeaderError as e:
|
||||||
eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {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(
|
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:
|
except UnicodeDecodeError as e:
|
||||||
eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}")
|
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(
|
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:
|
except Exception as e:
|
||||||
eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")
|
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(
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
sig = _signature(parsed.columns)
|
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)
|
# Stagingul in DB (tranzactie explicita)
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
@@ -2978,18 +3046,17 @@ async def web_upload_import(
|
|||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Mapare retinuta → computa preview imediat
|
# 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):
|
if isinstance(result, str):
|
||||||
return templates.TemplateResponse("_upload.html", {
|
return templates.TemplateResponse("_upload.html", _ctx(
|
||||||
"request": request,
|
request, error=result, **env_ctx
|
||||||
"error": result,
|
))
|
||||||
"csrf_token": get_csrf_token(request),
|
|
||||||
})
|
|
||||||
return templates.TemplateResponse("_preview_import.html", {
|
return templates.TemplateResponse("_preview_import.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": batch_id_int,
|
"import_id": batch_id_int,
|
||||||
"message": "Mapare retinuta aplicata automat.",
|
"message": "Mapare retinuta aplicata automat.",
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
|
"rar_env": upload_env,
|
||||||
**result,
|
**result,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3012,6 +3079,7 @@ async def web_upload_import(
|
|||||||
"canonical_fields": _CANONICAL_FIELDS,
|
"canonical_fields": _CANONICAL_FIELDS,
|
||||||
"format_data": None,
|
"format_data": None,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
|
"rar_env": upload_env,
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -3129,8 +3197,9 @@ async def web_save_mapare_coloane(
|
|||||||
(import_id, acct),
|
(import_id, acct),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not batch:
|
if not batch:
|
||||||
|
env_ctx = _import_env_ctx(conn, account_id)
|
||||||
return templates.TemplateResponse("_upload.html", _ctx(
|
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
|
# Semnatura = antetul COMPLET al fisierului (toate coloanele, inclusiv cele
|
||||||
@@ -3148,12 +3217,20 @@ async def web_save_mapare_coloane(
|
|||||||
(acct, sig, json.dumps(json_mapare, ensure_ascii=False), format_data_val),
|
(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
|
# 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):
|
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(
|
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||||
request, import_id=import_id, **result
|
request, import_id=import_id, rar_env=mapare_env, **result
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -3163,22 +3240,31 @@ async def web_save_mapare_coloane(
|
|||||||
def web_preview_import(
|
def web_preview_import(
|
||||||
request: Request,
|
request: Request,
|
||||||
import_id: int,
|
import_id: int,
|
||||||
|
rar_env: str | None = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
||||||
account_id = require_login(request)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
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):
|
if isinstance(result, str):
|
||||||
|
env_ctx = _import_env_ctx(conn, account_id)
|
||||||
return templates.TemplateResponse("_upload.html", {
|
return templates.TemplateResponse("_upload.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"error": result,
|
"error": result,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
|
**env_ctx,
|
||||||
})
|
})
|
||||||
return templates.TemplateResponse("_preview_import.html", {
|
return templates.TemplateResponse("_preview_import.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": import_id,
|
"import_id": import_id,
|
||||||
"csrf_token": get_csrf_token(request),
|
"csrf_token": get_csrf_token(request),
|
||||||
|
"rar_env": preview_env,
|
||||||
**result,
|
**result,
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
@@ -3601,10 +3687,13 @@ async def web_mapare_operatii(
|
|||||||
@router.get("/_import/reset", response_class=HTMLResponse)
|
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||||
def web_import_reset(request: Request) -> HTMLResponse:
|
def web_import_reset(request: Request) -> HTMLResponse:
|
||||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||||
return templates.TemplateResponse("_upload.html", {
|
account_id = require_login(request)
|
||||||
"request": request,
|
conn = get_connection()
|
||||||
"csrf_token": get_csrf_token(request),
|
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)
|
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
||||||
@@ -3631,6 +3720,9 @@ async def web_confirma_import(
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
n_confirmat = 0
|
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
|
# 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).
|
# 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'
|
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
|
||||||
@@ -3798,6 +3890,12 @@ async def web_confirma_import(
|
|||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, acct)
|
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)
|
# Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
enqueued: list[dict] = []
|
enqueued: list[dict] = []
|
||||||
toctou: list[int] = []
|
toctou: list[int] = []
|
||||||
@@ -3857,7 +3955,7 @@ async def web_confirma_import(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
|
|
||||||
key = build_key(account_id, canon)
|
key = build_key(account_id, canon, env)
|
||||||
|
|
||||||
rows_for_hash.append(json.dumps({
|
rows_for_hash.append(json.dumps({
|
||||||
"row_index": row_index,
|
"row_index": row_index,
|
||||||
@@ -3872,9 +3970,9 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT OR IGNORE INTO submissions "
|
"INSERT OR IGNORE INTO submissions "
|
||||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after) "
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
||||||
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'))",
|
"VALUES (?, ?, 'queued', ?, ?, ?, datetime('now', '+90 days'), ?)",
|
||||||
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index),
|
(key, acct, json.dumps(mapped, ensure_ascii=False), import_id, row_index, env),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
toctou.append(row_index)
|
toctou.append(row_index)
|
||||||
@@ -3922,8 +4020,9 @@ async def web_confirma_import(
|
|||||||
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
|
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
|
||||||
|
|
||||||
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
|
# 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(
|
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)
|
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
|
||||||
status_html = templates.get_template("_status.html").render(status_ctx)
|
status_html = templates.get_template("_status.html").render(status_ctx)
|
||||||
@@ -3943,6 +4042,66 @@ async def web_confirma_import(
|
|||||||
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
||||||
# =========================================================================== #
|
# =========================================================================== #
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# US-007 (PRD 5.20): Validare credentiale RAR env-aware
|
||||||
|
#
|
||||||
|
# Premisa confirmata live (2026-06-29): creds de productie NU se valideaza pe RAR
|
||||||
|
# test si invers (401 incrucisat). Deci login-ul de proba TREBUIE sa loveasca
|
||||||
|
# endpoint-ul mediului caruia ii apartin credentialele.
|
||||||
|
#
|
||||||
|
# Puncte de validare existente:
|
||||||
|
# - /cont/test-rar-creds (testeaza integrarea RAR, fara efecte secundare)
|
||||||
|
# Puncte non-aplicabile (nu colecteaza/valideaza creds RAR):
|
||||||
|
# - signup (/signup): nu colecteaza credentiale RAR — creare cont platforma, nu RAR
|
||||||
|
# - preview import: nu valideaza credentiale RAR
|
||||||
|
# Puncte viitoare (US-008):
|
||||||
|
# - /cont/rar-creds la salvare creds per-mediu (va apela _valideaza_login_rar)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _eticheta_mediu_rar(env: str) -> str:
|
||||||
|
"""Eticheta umana a mediului RAR pentru mesaje de eroare/succes.
|
||||||
|
|
||||||
|
'test' -> 'TESTARE', 'prod' -> 'PRODUCTIE'.
|
||||||
|
"""
|
||||||
|
return "PRODUCTIE" if env == "prod" else "TESTARE"
|
||||||
|
|
||||||
|
|
||||||
|
def _valideaza_login_rar(
|
||||||
|
settings,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
env: str,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Valideaza credentialele RAR prin login pe mediul specificat (US-007, PRD 5.20).
|
||||||
|
|
||||||
|
Creeaza un RarClient cu base_url-ul mediului `env` (NU base_url-ul global),
|
||||||
|
deoarece RAR test si RAR prod sunt sisteme separate cu credentiale separate.
|
||||||
|
|
||||||
|
Parametri
|
||||||
|
---------
|
||||||
|
settings: configuratia aplicatiei (pentru base_url_test/prod si timeout)
|
||||||
|
email: email-ul contului RAR
|
||||||
|
password: parola contului RAR
|
||||||
|
env: mediul tinta: 'test' sau 'prod'
|
||||||
|
|
||||||
|
Returneaza
|
||||||
|
----------
|
||||||
|
(True, None) la succes (login reusit)
|
||||||
|
(False, mesaj) la esec; `mesaj` include eticheta mediului ('TESTARE'/'PRODUCTIE'),
|
||||||
|
ex. 'Credentiale RAR invalide pe TESTARE.'
|
||||||
|
"""
|
||||||
|
env_label = _eticheta_mediu_rar(env)
|
||||||
|
try:
|
||||||
|
with RarClient(settings, base_url=base_url_pentru_env(settings, env)) as rar:
|
||||||
|
rar.login(email, password)
|
||||||
|
return True, None
|
||||||
|
except RarAuthError:
|
||||||
|
return False, f"Credentiale RAR invalide pe {env_label}."
|
||||||
|
except RarError as exc:
|
||||||
|
return False, f"Eroare la conectare RAR ({env_label}): {exc}"
|
||||||
|
|
||||||
|
|
||||||
def _render_cont(
|
def _render_cont(
|
||||||
request: Request,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
@@ -3954,6 +4113,19 @@ def _render_cont(
|
|||||||
account_meta: dict | None = None,
|
account_meta: dict | None = None,
|
||||||
date_firma_mesaj: str | None = None,
|
date_firma_mesaj: str | None = None,
|
||||||
date_firma_eroare: str | None = None,
|
date_firma_eroare: str | None = None,
|
||||||
|
# Per-env (US-008, PRD 5.20): starea mediilor RAR Testare + Productie.
|
||||||
|
test_enabled: bool = False,
|
||||||
|
prod_enabled: bool = True,
|
||||||
|
test_disponibil: bool = False,
|
||||||
|
prod_disponibil: bool = False,
|
||||||
|
rar_env_default: str = "prod",
|
||||||
|
medii_disponibile: list | None = None,
|
||||||
|
creds_test_mesaj: str | None = None,
|
||||||
|
creds_test_eroare: str | None = None,
|
||||||
|
creds_prod_mesaj: str | None = None,
|
||||||
|
creds_prod_eroare: str | None = None,
|
||||||
|
creds_default_eroare: str | None = None,
|
||||||
|
creds_default_mesaj: str | None = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -3968,6 +4140,18 @@ def _render_cont(
|
|||||||
account_meta=account_meta or {},
|
account_meta=account_meta or {},
|
||||||
date_firma_mesaj=date_firma_mesaj,
|
date_firma_mesaj=date_firma_mesaj,
|
||||||
date_firma_eroare=date_firma_eroare,
|
date_firma_eroare=date_firma_eroare,
|
||||||
|
test_enabled=test_enabled,
|
||||||
|
prod_enabled=prod_enabled,
|
||||||
|
test_disponibil=test_disponibil,
|
||||||
|
prod_disponibil=prod_disponibil,
|
||||||
|
rar_env_default=rar_env_default,
|
||||||
|
medii_disponibile=medii_disponibile or [],
|
||||||
|
creds_test_mesaj=creds_test_mesaj,
|
||||||
|
creds_test_eroare=creds_test_eroare,
|
||||||
|
creds_prod_mesaj=creds_prod_mesaj,
|
||||||
|
creds_prod_eroare=creds_prod_eroare,
|
||||||
|
creds_default_eroare=creds_default_eroare,
|
||||||
|
creds_default_mesaj=creds_default_mesaj,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3987,6 +4171,56 @@ def _fetch_account_meta(conn, acct: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_cont_env_state(conn, acct: int) -> dict:
|
||||||
|
"""Starea mediilor RAR per env pentru sectiunea 'Credentiale RAR' din _cont.html (US-008).
|
||||||
|
|
||||||
|
Returneaza un dict compatibil cu parametrii per-env ai _render_cont:
|
||||||
|
are_creds -- True daca ORICE credentiale RAR sunt configurate (legacy SAU per-env)
|
||||||
|
test_enabled -- bifa activare Testare
|
||||||
|
prod_enabled -- bifa activare Productie
|
||||||
|
test_disponibil -- Testare activata SI cu creds (poate trimite)
|
||||||
|
prod_disponibil -- Productie activata SI cu creds (poate trimite)
|
||||||
|
rar_env_default -- mediul implicit al contului
|
||||||
|
medii_disponibile -- lista mediilor disponibile (subset din ['test','prod'])
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT rar_test_enabled, rar_prod_enabled, "
|
||||||
|
"rar_creds_test_enc, rar_creds_prod_enc, rar_env_default, rar_creds_enc "
|
||||||
|
"FROM accounts WHERE id=?", (acct,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"are_creds": False,
|
||||||
|
"test_enabled": False,
|
||||||
|
"prod_enabled": True,
|
||||||
|
"test_disponibil": False,
|
||||||
|
"prod_disponibil": False,
|
||||||
|
"rar_env_default": "prod",
|
||||||
|
"medii_disponibile": [],
|
||||||
|
}
|
||||||
|
test_enabled = bool(row["rar_test_enabled"])
|
||||||
|
prod_enabled = bool(row["rar_prod_enabled"])
|
||||||
|
test_disponibil = test_enabled and bool(row["rar_creds_test_enc"])
|
||||||
|
prod_disponibil = prod_enabled and bool(row["rar_creds_prod_enc"])
|
||||||
|
medii: list[str] = []
|
||||||
|
if test_disponibil:
|
||||||
|
medii.append("test")
|
||||||
|
if prod_disponibil:
|
||||||
|
medii.append("prod")
|
||||||
|
are_creds = bool(
|
||||||
|
row["rar_creds_enc"] or row["rar_creds_test_enc"] or row["rar_creds_prod_enc"]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"are_creds": are_creds,
|
||||||
|
"test_enabled": test_enabled,
|
||||||
|
"prod_enabled": prod_enabled,
|
||||||
|
"test_disponibil": test_disponibil,
|
||||||
|
"prod_disponibil": prod_disponibil,
|
||||||
|
"rar_env_default": row["rar_env_default"] or "prod",
|
||||||
|
"medii_disponibile": medii,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
||||||
def fragment_cont(request: Request) -> HTMLResponse:
|
def fragment_cont(request: Request) -> HTMLResponse:
|
||||||
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma."""
|
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma."""
|
||||||
@@ -3994,12 +4228,9 @@ def fragment_cont(request: Request) -> HTMLResponse:
|
|||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
||||||
).fetchone()
|
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
return _render_cont(request, are_creds=are_creds, account_meta=account_meta)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
|
return _render_cont(request, account_meta=account_meta, **env_ctx)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -4016,12 +4247,9 @@ def cont_roteste_cheie(
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
new_key = rotate_api_key(conn, acct)
|
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"])
|
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
return _render_cont(request, api_key=new_key, are_creds=are_creds, account_meta=account_meta)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
|
return _render_cont(request, api_key=new_key, account_meta=account_meta, **env_ctx)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -4050,15 +4278,14 @@ async def cont_date_firma(request: Request) -> HTMLResponse:
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta=account_meta,
|
account_meta=account_meta,
|
||||||
date_firma_eroare="Compania (numele firmei) este obligatorie.",
|
date_firma_eroare="Compania (numele firmei) este obligatorie.",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalizare si validare email
|
# Normalizare si validare email
|
||||||
@@ -4067,31 +4294,27 @@ async def cont_date_firma(request: Request) -> HTMLResponse:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
||||||
date_firma_eroare=f"Email invalid: {exc}",
|
date_firma_eroare=f"Email invalid: {exc}",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not email_norm:
|
if not email_norm:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
|
||||||
date_firma_eroare="Email-ul de contact este obligatoriu.",
|
date_firma_eroare="Email-ul de contact este obligatoriu.",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalizare si validare CUI
|
# Normalizare si validare CUI
|
||||||
@@ -4100,31 +4323,27 @@ async def cont_date_firma(request: Request) -> HTMLResponse:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
||||||
date_firma_eroare=f"CUI invalid: {exc}",
|
date_firma_eroare=f"CUI invalid: {exc}",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not cui_norm:
|
if not cui_norm:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
|
||||||
date_firma_eroare="CUI-ul firmei este obligatoriu.",
|
date_firma_eroare="CUI-ul firmei este obligatoriu.",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Actualizare in DB
|
# Actualizare in DB
|
||||||
@@ -4142,26 +4361,24 @@ async def cont_date_firma(request: Request) -> HTMLResponse:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
owner = existing["id"] if existing else "?"
|
owner = existing["id"] if existing else "?"
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm},
|
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm},
|
||||||
date_firma_eroare=(
|
date_firma_eroare=(
|
||||||
f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). "
|
f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). "
|
||||||
"Foloseste un CUI diferit sau contacteaza administratorul."
|
"Foloseste un CUI diferit sau contacteaza administratorul."
|
||||||
),
|
),
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
|
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
account_meta=account_meta,
|
account_meta=account_meta,
|
||||||
date_firma_mesaj="Datele firmei au fost salvate.",
|
date_firma_mesaj="Datele firmei au fost salvate.",
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -4244,18 +4461,15 @@ def cont_rar_creds(
|
|||||||
if not email or not parola:
|
if not email or not parola:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
|
||||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
|
||||||
).fetchone()
|
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
|
||||||
creds_eroare="Email si parola sunt obligatorii.",
|
creds_eroare="Email si parola sunt obligatorii.",
|
||||||
account_meta=account_meta,
|
account_meta=account_meta,
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
enc = encrypt_creds({"email": email, "password": parola})
|
enc = encrypt_creds({"email": email, "password": parola})
|
||||||
@@ -4266,11 +4480,222 @@ def cont_rar_creds(
|
|||||||
(enc, acct),
|
(enc, acct),
|
||||||
)
|
)
|
||||||
account_meta = _fetch_account_meta(conn, acct)
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=True,
|
|
||||||
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
||||||
account_meta=account_meta,
|
account_meta=account_meta,
|
||||||
|
**env_ctx,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cont/test-rar-creds", response_class=HTMLResponse)
|
||||||
|
def cont_test_rar_creds(
|
||||||
|
request: Request,
|
||||||
|
rar_email: str = Form(""),
|
||||||
|
rar_parola: str = Form(""),
|
||||||
|
rar_env: str = Form(default=""),
|
||||||
|
csrf_token: str | None = Form(None),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Testeaza credentialele RAR prin login real pe mediul specificat (US-007, PRD 5.20).
|
||||||
|
|
||||||
|
Fara efecte secundare: nu salveaza nimic, nu creeaza submission. Pur validare.
|
||||||
|
Camp parola NICIODATA re-pus in raspuns.
|
||||||
|
|
||||||
|
Decizie env (documentata US-007):
|
||||||
|
- param `rar_env` explicit ('test'/'prod') -> folosit direct
|
||||||
|
- altfel -> rar_env_efectiv_cont (default-ul contului) sau ancora globala settings.rar_env
|
||||||
|
- signup nu colecteaza creds RAR, deci nu apeleaza aceasta functie
|
||||||
|
"""
|
||||||
|
account_id = require_login(request)
|
||||||
|
verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
email = rar_email.strip()
|
||||||
|
parola = rar_parola.strip()
|
||||||
|
|
||||||
|
if not email or not parola:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_integrare_test_rezultat.html",
|
||||||
|
{"request": request, "succes": False,
|
||||||
|
"mesaj": "Email si parola sunt obligatorii."},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determina env-ul de validare
|
||||||
|
settings = get_settings()
|
||||||
|
env_cerut = (rar_env or "").strip().lower()
|
||||||
|
if env_cerut in ("test", "prod"):
|
||||||
|
env = env_cerut
|
||||||
|
else:
|
||||||
|
# Fallback: env-ul efectiv al contului (default) sau ancora globala
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
env = rar_env_efectiv_cont(conn, account_id) or settings.rar_env or "test"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
ok, mesaj_eroare = _valideaza_login_rar(settings, email, parola, env)
|
||||||
|
if ok:
|
||||||
|
env_label = _eticheta_mediu_rar(env)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_integrare_test_rezultat.html",
|
||||||
|
{"request": request, "succes": True,
|
||||||
|
"mesaj": f"Credentiale RAR valide pe {env_label}."},
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_integrare_test_rezultat.html",
|
||||||
|
{"request": request, "succes": False, "mesaj": mesaj_eroare},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== #
|
||||||
|
# US-008 (PRD 5.20): Configurare medii RAR per cont — Testare + Productie. #
|
||||||
|
# Ruta noua /cont/rar-medii: gestioneaza bifa activare, credentiale si #
|
||||||
|
# mediul implicit separat pentru fiecare din cele doua medii RAR. #
|
||||||
|
# =========================================================================== #
|
||||||
|
|
||||||
|
@router.post("/cont/rar-medii", response_class=HTMLResponse)
|
||||||
|
async def cont_rar_medii(request: Request) -> HTMLResponse:
|
||||||
|
"""Salveaza configuratia mediilor RAR per cont (US-008, PRD 5.20).
|
||||||
|
|
||||||
|
Doua sectiuni independente (Testare / Productie): fiecare cu bifa de activare
|
||||||
|
si campuri email/parola. La salvare, pentru fiecare mediu activat cu creds noi:
|
||||||
|
- valideaza prin login pe acel env (US-007) — RAR test si prod sunt sisteme separate;
|
||||||
|
- OK -> cripteaza cu Fernet si scrie in rar_creds_{env}_enc + enabled=1;
|
||||||
|
- esec login -> eroare per-env, mediul NU devine disponibil (creds nesalvate).
|
||||||
|
|
||||||
|
Activarea Productie pentru prima oara necesita checkbox-ul de confirmare
|
||||||
|
(constientizare L.142 — trimiterile sunt declaratii oficiale, finale si fara anulare).
|
||||||
|
|
||||||
|
Mediul implicit (rar_env_default) poate fi setat DOAR pe un mediu disponibil
|
||||||
|
(validat server-side post-update; altfel eroare, valoarea veche ramane).
|
||||||
|
|
||||||
|
Parolele NICIODATA reflectate inapoi in pagina (camp gol cu placeholder).
|
||||||
|
"""
|
||||||
|
account_id = require_login(request)
|
||||||
|
form = await request.form()
|
||||||
|
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||||
|
acct = account_or_default(account_id)
|
||||||
|
|
||||||
|
test_enabled_form = form.get("test_enabled") == "1"
|
||||||
|
prod_enabled_form = form.get("prod_enabled") == "1"
|
||||||
|
prod_confirmare = form.get("prod_confirmare") == "1"
|
||||||
|
test_email = str(form.get("test_email") or "").strip()
|
||||||
|
test_parola = str(form.get("test_parola") or "").strip()
|
||||||
|
prod_email = str(form.get("prod_email") or "").strip()
|
||||||
|
prod_parola = str(form.get("prod_parola") or "").strip()
|
||||||
|
rar_env_default_form = str(form.get("rar_env_default") or "").strip()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
creds_test_eroare: str | None = None
|
||||||
|
creds_test_mesaj: str | None = None
|
||||||
|
creds_prod_eroare: str | None = None
|
||||||
|
creds_prod_mesaj: str | None = None
|
||||||
|
creds_default_eroare: str | None = None
|
||||||
|
creds_default_mesaj: str | None = None
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
# Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod.
|
||||||
|
row_before = conn.execute(
|
||||||
|
"SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct,)
|
||||||
|
).fetchone()
|
||||||
|
was_prod_enabled = bool(row_before["rar_prod_enabled"]) if row_before else False
|
||||||
|
|
||||||
|
# Confirmare obligatorie la PRIMA activare Productie (constientizare L.142).
|
||||||
|
# Nu se cere daca Productie era deja activata (confirmare unica per-activare).
|
||||||
|
if prod_enabled_form and not was_prod_enabled and not prod_confirmare:
|
||||||
|
creds_prod_eroare = (
|
||||||
|
"Bifati confirmarea de mai jos pentru a activa mediul Productie: "
|
||||||
|
"\"Inteleg ca trimiterile pe Productie sunt declaratii reale (L.142)\"."
|
||||||
|
)
|
||||||
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
|
return _render_cont(
|
||||||
|
request,
|
||||||
|
account_meta=account_meta,
|
||||||
|
creds_prod_eroare=creds_prod_eroare,
|
||||||
|
**env_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Procesare Testare ---
|
||||||
|
if test_enabled_form:
|
||||||
|
if test_email and test_parola:
|
||||||
|
# Ambele campuri completate -> valideaza prin login pe RAR Testare.
|
||||||
|
ok, mesaj = _valideaza_login_rar(settings, test_email, test_parola, "test")
|
||||||
|
if ok:
|
||||||
|
enc = encrypt_creds({"email": test_email, "password": test_parola})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=? WHERE id=?",
|
||||||
|
(enc, acct),
|
||||||
|
)
|
||||||
|
creds_test_mesaj = "Credentiale Testare salvate si validate."
|
||||||
|
else:
|
||||||
|
# Login esuat: nu schimbam creds sau enabled; eroare per-env.
|
||||||
|
creds_test_eroare = mesaj
|
||||||
|
elif test_email or test_parola:
|
||||||
|
# Doar unul din campuri completat -> eroare (nu pot fi partial).
|
||||||
|
creds_test_eroare = "Email si parola Testare trebuie completate impreuna."
|
||||||
|
else:
|
||||||
|
# Activat fara creds noi -> marcheaza enabled (creds existente, daca sunt, raman).
|
||||||
|
conn.execute("UPDATE accounts SET rar_test_enabled=1 WHERE id=?", (acct,))
|
||||||
|
else:
|
||||||
|
# Dezactivat -> disabled=0; creds raman pentru posibila re-activare ulterioara.
|
||||||
|
conn.execute("UPDATE accounts SET rar_test_enabled=0 WHERE id=?", (acct,))
|
||||||
|
|
||||||
|
# --- Procesare Productie ---
|
||||||
|
if prod_enabled_form:
|
||||||
|
if prod_email and prod_parola:
|
||||||
|
ok, mesaj = _valideaza_login_rar(settings, prod_email, prod_parola, "prod")
|
||||||
|
if ok:
|
||||||
|
enc = encrypt_creds({"email": prod_email, "password": prod_parola})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=? WHERE id=?",
|
||||||
|
(enc, acct),
|
||||||
|
)
|
||||||
|
creds_prod_mesaj = "Credentiale Productie salvate si validate."
|
||||||
|
else:
|
||||||
|
creds_prod_eroare = mesaj
|
||||||
|
elif prod_email or prod_parola:
|
||||||
|
creds_prod_eroare = "Email si parola Productie trebuie completate impreuna."
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE accounts SET rar_prod_enabled=1 WHERE id=?", (acct,))
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE accounts SET rar_prod_enabled=0 WHERE id=?", (acct,))
|
||||||
|
|
||||||
|
# --- Mediu implicit (validat post-update contra mediilor disponibile) ---
|
||||||
|
if rar_env_default_form and rar_env_default_form in ("test", "prod"):
|
||||||
|
from ..rar_env import medii_disponibile as _medii_disponibile_fn
|
||||||
|
row_after = conn.execute(
|
||||||
|
"SELECT rar_test_enabled, rar_prod_enabled, "
|
||||||
|
"rar_creds_test_enc, rar_creds_prod_enc "
|
||||||
|
"FROM accounts WHERE id=?", (acct,)
|
||||||
|
).fetchone()
|
||||||
|
medii_post = _medii_disponibile_fn(row_after)
|
||||||
|
if rar_env_default_form in medii_post:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_env_default=? WHERE id=?",
|
||||||
|
(rar_env_default_form, acct),
|
||||||
|
)
|
||||||
|
creds_default_mesaj = "Mediu implicit actualizat."
|
||||||
|
else:
|
||||||
|
creds_default_eroare = (
|
||||||
|
"Mediul ales nu e disponibil — activeaza-l si adauga credentiale valide intai."
|
||||||
|
)
|
||||||
|
|
||||||
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
|
env_ctx = _fetch_cont_env_state(conn, acct)
|
||||||
|
return _render_cont(
|
||||||
|
request,
|
||||||
|
account_meta=account_meta,
|
||||||
|
creds_test_mesaj=creds_test_mesaj,
|
||||||
|
creds_test_eroare=creds_test_eroare,
|
||||||
|
creds_prod_mesaj=creds_prod_mesaj,
|
||||||
|
creds_prod_eroare=creds_prod_eroare,
|
||||||
|
creds_default_eroare=creds_default_eroare,
|
||||||
|
creds_default_mesaj=creds_default_mesaj,
|
||||||
|
**env_ctx,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -114,36 +114,106 @@
|
|||||||
|
|
||||||
<!-- Sectiunea: Credentiale RAR -->
|
<!-- Sectiunea: Credentiale RAR -->
|
||||||
<div>
|
<div>
|
||||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
|
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 12px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
|
||||||
|
|
||||||
{% if are_creds %}
|
|
||||||
<div class="flash" style="margin-bottom:12px;">Credentiale RAR configurate.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if creds_mesaj %}
|
{% if creds_mesaj %}
|
||||||
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
|
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if creds_eroare %}
|
{% if creds_eroare %}
|
||||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
|
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form hx-post="/cont/rar-creds"
|
<form hx-post="/cont/rar-medii"
|
||||||
hx-target="#card-cont"
|
hx-target="#card-cont"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<!-- Subsectiunea: Testare -->
|
||||||
|
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
|
||||||
|
<p style="margin:0 0 6px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" name="test_enabled" value="1" {% if test_enabled %}checked{% endif %}>
|
||||||
|
Activare Testare
|
||||||
|
</label>
|
||||||
|
{% if test_disponibil %}
|
||||||
|
<span style="font-size:12px; color:var(--ok);">configurat</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
<p style="margin:0 0 8px;">
|
<p style="margin:0 0 8px;">
|
||||||
<label style="font-size:13px; color:var(--muted);">Email RAR</label><br>
|
<label style="font-size:13px; color:var(--muted);">Email RAR Testare</label><br>
|
||||||
<input type="email" name="rar_email" required style="width:100%; max-width:340px;"
|
<input type="email" name="test_email" style="width:100%; max-width:340px;"
|
||||||
placeholder="email@service.ro">
|
placeholder="email@service.ro">
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0 0 12px;">
|
<p style="margin:0 0 8px;">
|
||||||
<label style="font-size:13px; color:var(--muted);">Parola RAR</label><br>
|
<label style="font-size:13px; color:var(--muted);">Parola RAR Testare</label><br>
|
||||||
<input type="password" name="rar_parola" required style="width:100%; max-width:340px;"
|
<input type="password" name="test_parola" style="width:100%; max-width:340px;"
|
||||||
autocomplete="new-password">
|
autocomplete="new-password">
|
||||||
</p>
|
</p>
|
||||||
<button type="submit">Salveaza credentiale RAR</button>
|
{% if creds_test_mesaj %}
|
||||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parola stocata criptat, niciodata in clar.</span>
|
<div class="flash" style="margin-top:6px;">{{ creds_test_mesaj }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if creds_test_eroare %}
|
||||||
|
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_test_eroare }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subsectiunea: Productie -->
|
||||||
|
<div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--line);">
|
||||||
|
<p style="margin:0 0 6px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" name="prod_enabled" value="1" {% if prod_enabled %}checked{% endif %}>
|
||||||
|
Activare Productie
|
||||||
|
</label>
|
||||||
|
{% if prod_disponibil %}
|
||||||
|
<span style="font-size:12px; color:var(--ok);">configurat</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted);">Email RAR Productie</label><br>
|
||||||
|
<input type="email" name="prod_email" style="width:100%; max-width:340px;"
|
||||||
|
placeholder="email@service.ro">
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted);">Parola RAR Productie</label><br>
|
||||||
|
<input type="password" name="prod_parola" style="width:100%; max-width:340px;"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted); display:flex; align-items:flex-start; gap:6px;">
|
||||||
|
<input type="checkbox" name="prod_confirmare" value="1" style="margin-top:2px; flex-shrink:0;">
|
||||||
|
Inteleg ca trimiterile pe Productie sunt declaratii reale (L.142), finale si fara anulare.
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
{% if creds_prod_mesaj %}
|
||||||
|
<div class="flash" style="margin-top:6px;">{{ creds_prod_mesaj }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if creds_prod_eroare %}
|
||||||
|
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_prod_eroare }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selector mediu implicit -->
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="font-size:13px; color:var(--muted);">Mediu implicit pentru trimiteri</label><br>
|
||||||
|
{% if medii_disponibile %}
|
||||||
|
<select name="rar_env_default" style="width:100%; max-width:340px; margin-top:4px;">
|
||||||
|
{% for env in medii_disponibile %}
|
||||||
|
<option value="{{ env }}"{% if env == rar_env_default %} selected{% endif %}>{{ "Testare" if env == "test" else "Productie" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<p style="font-size:13px; color:var(--muted); margin:4px 0 0;">Activeaza si valideaza un mediu intai.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if creds_default_mesaj %}
|
||||||
|
<div class="flash" style="margin-top:6px;">{{ creds_default_mesaj }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if creds_default_eroare %}
|
||||||
|
<div class="banner" style="margin-top:6px; padding:8px 12px;">{{ creds_default_eroare }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Salveaza mediile RAR</button>
|
||||||
|
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parolele stocate criptat, niciodata in clar.</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Formular test conexiune #}
|
{# Formular test conexiune cheie API #}
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
<h3 style="margin:0 0 12px; font-size:15px;">Testeaza conexiunea</h3>
|
<h3 style="margin:0 0 12px; font-size:15px;">Testeaza conexiunea</h3>
|
||||||
<form id="form-test-cheie"
|
<form id="form-test-cheie"
|
||||||
@@ -246,6 +246,42 @@
|
|||||||
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
|
<div id="integrare-test-rezultat" style="margin-top:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Formular test credentiale RAR (US-007, PRD 5.20) #}
|
||||||
|
{# Login de proba pe mediul ales — fara efecte secundare, nu salveaza nimic. #}
|
||||||
|
{# Banner-ul de rezultat include eticheta mediului ("pe TESTARE" / "pe PRODUCTIE"). #}
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<h3 style="margin:0 0 6px; font-size:15px;">Testeaza credentiale RAR</h3>
|
||||||
|
<p class="muted" style="font-size:12px; margin:0 0 10px;">
|
||||||
|
Verifica daca credentialele RAR sunt corecte pe mediul ales. Nu se salveaza nimic.
|
||||||
|
</p>
|
||||||
|
<form id="form-test-rar-creds"
|
||||||
|
hx-post="/cont/test-rar-creds"
|
||||||
|
hx-target="#rar-test-rezultat"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div>
|
||||||
|
<label for="test-rar-email" style="display:block; font-size:13px; color:var(--muted); margin-bottom:4px;">Email RAR</label>
|
||||||
|
<input type="email" id="test-rar-email" name="rar_email" placeholder="email@service.ro"
|
||||||
|
style="width:220px;" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="test-rar-parola" style="display:block; font-size:13px; color:var(--muted); margin-bottom:4px;">Parola RAR</label>
|
||||||
|
<input type="password" id="test-rar-parola" name="rar_parola"
|
||||||
|
style="width:160px;" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="test-rar-env" style="display:block; font-size:13px; color:var(--muted); margin-bottom:4px;">Mediu</label>
|
||||||
|
<select id="test-rar-env" name="rar_env" style="height:36px; padding:0 8px;">
|
||||||
|
<option value="prod">Productie</option>
|
||||||
|
<option value="test">Testare</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Testeaza RAR</button>
|
||||||
|
</form>
|
||||||
|
<div id="rar-test-rezultat" style="margin-top:8px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
Preview —
|
Preview —
|
||||||
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
|
||||||
</h2>
|
</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>
|
<span class="muted" style="margin-left:auto; font-size:var(--fs-sm);">{{ total }} randuri</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,6 +186,8 @@
|
|||||||
hx-target="#import-section"
|
hx-target="#import-section"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<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 class="sticky-bar">
|
||||||
<div style="flex:1; min-width:280px;">
|
<div style="flex:1; min-width:280px;">
|
||||||
<!-- Banner declarant — direct deasupra input-ului N -->
|
<!-- Banner declarant — direct deasupra input-ului N -->
|
||||||
|
|||||||
@@ -32,6 +32,38 @@
|
|||||||
hx-indicator="#upload-spinner">
|
hx-indicator="#upload-spinner">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<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 %}
|
{% if sheets %}
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
<label for="sheet-select"
|
<label for="sheet-select"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}ROMFAST AUTOPASS{% endblock %}</title>
|
<title>{% block title %}ROA AUTOPASS{% endblock %}</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
// Raspunsurile de editare-rand contin un <tr> (swap pe rand) PLUS
|
||||||
@@ -818,7 +818,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{# US-010 (PRD 5.16): antet branduit ROMFAST AUTOPASS.
|
{# US-010 (PRD 5.16): antet branduit ROA AUTOPASS.
|
||||||
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
Grila 3 coloane — stanga (logo) | centru (titlu+env+tier+account_name) | dreapta (RAR dot + tema + burger).
|
||||||
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
Antet MINIMAL pe /login: nu afiseaza RAR dot, meniu burger sau account_name (nelogat). #}
|
||||||
<header>
|
<header>
|
||||||
@@ -830,11 +830,11 @@
|
|||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{# Celula centru: titlu ROMFAST AUTOPASS + badge env + badge tier + sub-titlu account_name.
|
{# Celula centru: titlu ROA AUTOPASS + badge env + badge tier + sub-titlu account_name.
|
||||||
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
|
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<a href="/" style="text-decoration:none; color:inherit;">
|
<a href="/" style="text-decoration:none; color:inherit;">
|
||||||
<h1>ROMFAST AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
|
<h1>ROA AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if is_authenticated|default(false) and account_name|default('') %}
|
{% if is_authenticated|default(false) and account_name|default('') %}
|
||||||
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
|
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Gateway RAR AUTOPASS — declară automat la RAR | ROMFAST</title>
|
<title>ROA AUTOPASS — declari prestațiile la RAR din câteva click-uri</title>
|
||||||
<meta name="description" content="Gateway web care declară prestațiile de service-auto la RAR AUTOPASS, automat. Conform Legii 142/2023. Gratuit până la 60 de prestații/lună, fără card bancar.">
|
<meta name="description" content="Încarci fișierul tău cu operațiile service-ului, completezi o dată codurile RAR și le salvezi. ROMFAST trimite prestațiile la RAR AUTOPASS în locul tău, fără tastat manual. Conform Legii 142/2023.">
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
/* US-001/US-008 (PRD 5.16): IBM Plex eliminat complet — stive font sistem standard web.
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
.page [style*="grid-template-columns"]{grid-template-columns:1fr!important;}
|
||||||
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
.page [style*="padding:80px 40px"]{padding:48px 20px!important;}
|
||||||
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
.page [style*="padding:0 40px 80px"]{padding:0 20px 48px!important;}
|
||||||
|
.page [style*="padding:56px 40px 80px"]{padding:36px 20px 48px!important;}
|
||||||
.page [style*="padding:44px"]{padding:28px!important;}
|
.page [style*="padding:44px"]{padding:28px!important;}
|
||||||
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
.page [style*="padding:56px 40px"]{padding:40px 22px!important;}
|
||||||
.page [style*="height:68px"]{height:60px!important;}
|
.page [style*="height:68px"]{height:60px!important;}
|
||||||
@@ -56,12 +57,17 @@
|
|||||||
<main class="page">
|
<main class="page">
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
<div class="lp-header" style="position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:0 40px;height:68px;background:var(--hbg,rgba(15,18,24,.88));backdrop-filter:blur(8px);border-bottom:1px solid var(--line,#262b36);z-index:5;">
|
||||||
<div style="display:flex;align-items:center;gap:48px;">
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
<img src="/static/romfast_logo.png" alt="ROMFAST" style="height:38px;width:auto;display:block;" />
|
||||||
|
<div style="display:flex;flex-direction:column;line-height:1.05;">
|
||||||
|
<span style="font:700 17px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);">ROA AUTOPASS</span>
|
||||||
|
<span style="font:500 11px var(--font-ui);letter-spacing:.04em;color:var(--sub,#8b93a7);">Gateway RAR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:28px;">
|
||||||
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
<div class="lp-nav" style="display:flex;gap:28px;font:500 14px var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
<a href="#cum-functioneaza" style="color:inherit;text-decoration:none;">Cum funcționează</a><a href="#api" style="color:inherit;text-decoration:none;">API</a><a href="#pret" style="color:inherit;text-decoration:none;">Preț</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
<div class="lp-hactions" style="display:flex;align-items:center;gap:12px;">
|
||||||
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
<button data-act="theme" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 13px var(--font-ui);cursor:pointer;">
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
@@ -71,16 +77,16 @@
|
|||||||
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
|
<button data-act="auth" data-tab="register" style="height:44px;padding:0 18px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;padding:80px 40px 72px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:12px;padding:7px 14px;border-radius:99px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 13px var(--font-ui);margin-bottom:24px;flex-wrap:wrap;">
|
|
||||||
<span style="display:inline-flex;align-items:center;gap:8px;"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Este gratuit pentru service-urile mici — până la 60 de trimiteri RAR/lună</span>
|
|
||||||
<a data-act="auth" data-tab="register" style="display:inline-flex;align-items:center;gap:5px;color:var(--accent,#2E74D6);font-weight:700;cursor:pointer;text-decoration:none;" >Creează cont în 2 minute <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
|
|
||||||
</div>
|
|
||||||
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
<h1 class="lp-h1" style="font:700 50px/1.06 var(--font-ui);letter-spacing:-.025em;margin:0 0 20px;color:var(--text,#e6e9ef);">Declară prestațiile la RAR AUTOPASS, automat</h1>
|
||||||
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 32px;max-width:480px;">Conformitate RAR fără bătaie de cap. Încarci un fișier sau conectezi softul de service — noi trimitem prezentările la RAR în siguranță, conform Legii 142/2023.</p>
|
<p style="font:400 17px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;max-width:480px;">Încarci fișierul tău așa cum îl ai, potrivești o dată operațiile cu codurile RAR, și trimitem prestațiile la RAR AUTOPASS în locul tău. Fără tastat câmp cu câmp.</p>
|
||||||
|
<div style="margin-bottom:32px;">
|
||||||
|
<p style="display:flex;align-items:center;gap:8px;font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1F9D5C" stroke-width="2.6" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg><span><span style="color:#1F9D5C;">Gratuit</span> până la 60 de trimiteri/lună</span></p>
|
||||||
|
</div>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
<div style="display:flex;gap:12px;margin-bottom:22px;">
|
||||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
<button data-act="auth" data-tab="register" style="height:50px;padding:0 26px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||||
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
|
<button style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi cum funcționează</button>
|
||||||
@@ -89,8 +95,6 @@
|
|||||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023</span>
|
||||||
<span style="color:var(--line,#262b36);">·</span>
|
<span style="color:var(--line,#262b36);">·</span>
|
||||||
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
<span style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="1.7"><rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Datele tale criptate</span>
|
||||||
<span style="color:var(--line,#262b36);">·</span>
|
|
||||||
<span>Fără card bancar</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,65 +135,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PROBLEM -->
|
<!-- PROBLEM + CALCULATOR (combinat) -->
|
||||||
<div style="padding:80px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
<div style="padding:80px 40px 40px;background:color-mix(in srgb,#E05D5D 6%,var(--bg,#0f1218));">
|
||||||
<div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:start;margin:0 auto;">
|
<div style="text-align:center;max-width:760px;margin:0 auto 40px;">
|
||||||
<div>
|
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 2–3 minute pe RAR AUTOPASS.<br><span style="color:var(--errt,#E05D5D);">Minutele acelea sunt bani.</span></h2>
|
||||||
<h2 style="font:700 38px/1.14 var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Pentru fiecare comandă stai 2–3 minute și tastezi pe rar-autopass.ro</h2>
|
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi. Mută cursorul la volumul service-ului tău și vezi cât te costă.</p>
|
||||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 16px;">VIN, cod prestație, kilometraj, dată, tip operație — câmp cu câmp, comandă cu comandă. La 20 de mașini pe zi pierzi aproape o oră. În fiecare zi.</p>
|
|
||||||
<p style="font:400 16px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Iar dacă greșești o cifră din VIN, prestația e respinsă și o iei de la capăt — cu risc de amendă pentru raportare incompletă sau întârziată.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:20px;">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">rar-autopass.ro · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:stretch;">
|
||||||
|
<!-- STANGA: formularul RAR AUTOPASS -->
|
||||||
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:24px;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"><span style="font:500 12px var(--font-mono);color:var(--sub,#8b93a7);">RAR AUTOPASS · prestație nouă</span><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;background:color-mix(in srgb,#E05D5D 14%,transparent);color:var(--errt,#E05D5D);font:600 12px var(--font-mono);"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>02:34</span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă Vin</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Confirmă VIN</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">U1234567890123456</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Data prestației</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">2026-06-22</div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr Înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Număr înmatriculare</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">CT88NOE</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Observații</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-ui);color:var(--text,#e6e9ef);overflow:hidden;white-space:nowrap;">REVIZIE; SCHIMBARE PLĂCUȚE FRÂNĂ</div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Prestații</div><div style="min-height:30px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);"><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>REVIZIE PERIODICĂ</span><span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:5px;background:color-mix(in srgb,var(--accent,#2E74D6) 18%,transparent);color:var(--accent,#2E74D6);font:500 10px var(--font-ui);"><span style="opacity:.7;">×</span>ÎNTREȚINERE</span></div></div>
|
||||||
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
<div><div style="font:400 11px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:4px;">Valoarea citită a odometrului</div><div style="height:30px;display:flex;align-items:center;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);font:400 12px var(--font-mono);color:var(--text,#e6e9ef);">39000</div></div>
|
||||||
</div>
|
</div>
|
||||||
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
<button style="margin-top:14px;height:34px;padding:0 14px;border-radius:6px;background:color-mix(in srgb,var(--accent,#2E74D6) 40%,var(--card2,#0f1218));border:none;color:#fff;opacity:.55;font:600 12px var(--font-ui);cursor:not-allowed;align-self:flex-start;">Salvează Prezentarea</button>
|
||||||
<div style="margin-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
<div style="margin-top:auto;padding-top:12px;font:400 12px var(--font-ui);color:var(--sub,#8b93a7);text-align:center;">se repetă pentru fiecare comandă · zi de zi</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DREAPTA: calculatorul (slidere + cifre) -->
|
||||||
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;padding:34px;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:28px;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Trimiteri/lună</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);" id="out-pres">100</span></div>
|
||||||
|
<input type="range" min="50" max="1500" step="10" value="100" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px;"><span style="font:500 13px var(--font-ui);color:var(--text,#e6e9ef);">Manoperă</span><span style="font:700 24px var(--font-ui);letter-spacing:-.02em;color:var(--accent,#2E74D6);"><span id="out-rate">60</span><span style="font:500 12px var(--font-ui);color:var(--sub,#8b93a7);"> lei/h</span></span></div>
|
||||||
|
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card2,#0f1218));border:1px solid color-mix(in srgb,#E05D5D 28%,var(--line,#262b36));border-radius:10px;padding:22px 24px;">
|
||||||
|
<div style="font:600 11px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;">Pierdut pe raportare manuală</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/lună</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="hMonth">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">ore/lună</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiYear">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">lei/an</div></div>
|
||||||
|
<div><div style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="days">0</span></div><div style="font:400 12px var(--font-ui);color:var(--sub,#8b93a7);margin-top:2px;">zile/an</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px;padding-top:18px;border-top:1px solid var(--line,#262b36);">
|
||||||
|
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROA AUTOPASS: câteva secunde pentru tot lotul</div>
|
||||||
|
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;font:400 12px var(--font-ui);color:var(--mut,#5c6473);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de tastat manual pentru fiecare trimitere.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AGITATE / CALCULATOR -->
|
<!-- LEGE / AMENZI -->
|
||||||
<div style="padding:80px 40px;">
|
<div style="padding:56px 40px 80px;">
|
||||||
<div style="text-align:center;max-width:720px;margin:0 auto 40px;">
|
<div style="display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Cât te costă de fapt</div>
|
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 12px;color:var(--text,#e6e9ef);">Fă socoteala. Minutele acelea sunt bani.</h2>
|
|
||||||
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Mută cursorul la volumul service-ului tău și vezi cât timp și câți bani pleacă pe raportarea manuală.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:0 auto;align-items:stretch;">
|
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:32px;">
|
|
||||||
<div style="margin-bottom:28px;">
|
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Prestații pe lună</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);" id="out-pres">300</span></div>
|
|
||||||
<input type="range" min="50" max="1500" step="10" value="300" id="calc-pres" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:24px;">
|
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px;"><span style="font:500 14px var(--font-ui);color:var(--text,#e6e9ef);">Cost manoperă</span><span style="font:700 20px var(--font-mono);color:var(--accent,#2E74D6);"><span id="out-rate">60</span> lei/h</span></div>
|
|
||||||
<input type="range" min="30" max="200" step="5" value="60" id="calc-rate" data-act="calc" style="width:100%;height:6px;accent-color:var(--accent,#2E74D6);cursor:pointer;" />
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:9px;padding-top:18px;border-top:1px solid var(--line,#262b36);font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0;"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>Estimat la ~2,5 minute de introdus manual pentru fiecare prestație.</div>
|
|
||||||
</div>
|
|
||||||
<div style="background:color-mix(in srgb,#E05D5D 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E05D5D 32%,var(--line,#262b36));border-radius:12px;padding:32px;display:flex;flex-direction:column;justify-content:center;">
|
|
||||||
<div style="font:600 12px var(--font-ui);color:var(--errt,#E05D5D);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;">Pierdut pe raportare manuală</div>
|
|
||||||
<div style="display:flex;align-items:baseline;gap:8px;"><span style="font:700 52px/1 var(--font-ui);letter-spacing:-.02em;color:var(--errt,#E05D5D);"><span data-calc="leiMonth">0</span></span><span style="font:500 15px var(--font-ui);color:var(--sub,#8b93a7);">lei / lună</span></div>
|
|
||||||
<div style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin-top:8px;"><span data-calc="hMonth">0</span> ore pe lună · <span data-calc="leiYear">0</span> lei pe an · ≈ <span data-calc="days">0</span> zile lucrătoare/an doar cu raportarea.</div>
|
|
||||||
<div style="margin-top:20px;padding-top:18px;border-top:1px solid color-mix(in srgb,#E05D5D 24%,var(--line,#262b36));">
|
|
||||||
<div style="display:flex;align-items:center;gap:9px;font:600 14px var(--font-ui);color:var(--okt,#2FBF8F);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Cu ROMFAST: câteva secunde pentru tot lotul</div>
|
|
||||||
<div style="font:400 13px/1.55 var(--font-ui);color:var(--sub,#8b93a7);margin-top:6px;">Recuperezi ~<span data-calc="leiMonth">0</span> lei/lună și timpul îl pui pe mașini, nu pe formulare.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin:24px auto 0;display:flex;gap:20px;align-items:flex-start;background:color-mix(in srgb,#E0A93B 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#E0A93B 34%,var(--line,#262b36));border-radius:12px;padding:26px 28px;">
|
|
||||||
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
<div style="width:44px;height:44px;flex-shrink:0;border-radius:8px;background:color-mix(in srgb,#E0A93B 16%,transparent);display:flex;align-items:center;justify-content:center;color:#E0A93B;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9.5C7.1 20.3 4 16.4 4 12V7l8-4z"/><path d="M9.5 12l1.8 1.8L15 10"/></svg></div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
<div style="font:700 16px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:7px;">Evită riscul amenzilor — transmite automat la RAR Auto-Pass</div>
|
||||||
@@ -199,10 +205,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOLVE -->
|
<!-- SOLVE -->
|
||||||
<div id="cum-functioneaza" style="padding:80px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
<div id="cum-functioneaza" style="padding:80px 40px 40px;background:color-mix(in srgb,var(--accent,#2E74D6) 8%,var(--bg,#0f1218));border-top:1px solid var(--line,#262b36);border-bottom:1px solid var(--line,#262b36);">
|
||||||
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
<div style="max-width:780px;margin:0 auto;text-align:center;">
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 18px;color:var(--text,#e6e9ef);">Nu trebuie să fii bun cu calculatorul</h2>
|
||||||
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA Auto-Pass îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
<p style="font:400 19px/1.75 var(--font-ui);color:var(--sub,#8b93a7);margin:0 auto;max-width:660px;"><span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">Încarci</span> fișierul CSV/XLSX (sau trimiți direct prin API). ROA AUTOPASS îți propune asocierile — tu le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">confirmi sau corectezi</span> o singură dată — apoi le <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">trimitem</span> la RAR, iar tu doar <span style="color:var(--accent,#2E74D6);font-weight:700;text-decoration:underline;text-underline-offset:3px;">urmărești</span> pe ecran.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
<div style="text-align:center;max-width:880px;margin:38px auto 0;font:400 20px/1.6 var(--font-ui);color:var(--sub,#8b93a7);">
|
||||||
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">2–3 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;"> → câteva secunde pentru tot lotul.</span>
|
<span style="text-decoration:line-through;text-decoration-color:var(--errt,#E05D5D);text-decoration-thickness:2px;">2–3 minute de tastat pentru fiecare comandă</span><span style="color:var(--text,#e6e9ef);font-weight:700;"> → câteva secunde pentru tot lotul.</span>
|
||||||
@@ -210,10 +216,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API INTEGRATION -->
|
<!-- API INTEGRATION -->
|
||||||
<div id="api" style="padding:0 40px 80px;">
|
<div id="api" style="padding:56px 40px 80px;">
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:14px;display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:44px;align-items:center;">
|
||||||
<div>
|
<div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu · ROAAUTO</div>
|
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:99px;border:1px solid var(--line,#262b36);color:var(--sub,#8b93a7);font:500 12px var(--font-ui);margin-bottom:18px;">Pentru service-uri cu soft propriu</div>
|
||||||
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
|
<h2 style="font:700 30px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Ai deja un soft de service? Conectează-l automat</h2>
|
||||||
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
|
<p style="font:400 15px/1.65 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Softul tău se poate conecta și direct la API-ul RAR Auto-Pass. Cu ROMFAST primești în plus asistență la maparea automată a operațiilor tale (prin mai multe metode) și salvarea mapărilor pentru trimiterile viitoare — totul printr-un singur apel, cu cheie API per cont.</p>
|
||||||
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
|
<button style="height:44px;padding:0 20px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Vezi documentația API <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
|
||||||
@@ -236,70 +242,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TRIAL BENEFIT -->
|
||||||
|
<div style="padding:0 40px 80px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:22px;background:color-mix(in srgb,#2FBF8F 9%,var(--card,#181c24));border:1px solid color-mix(in srgb,#2FBF8F 32%,var(--line,#262b36));border-radius:14px;padding:30px 34px;flex-wrap:wrap;">
|
||||||
|
<div style="width:48px;height:48px;flex-shrink:0;border-radius:10px;background:color-mix(in srgb,#2FBF8F 16%,transparent);display:flex;align-items:center;justify-content:center;color:var(--okt,#2FBF8F);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||||
|
<div style="flex:1;min-width:240px;">
|
||||||
|
<div style="font:700 19px var(--font-ui);letter-spacing:-.01em;color:var(--text,#e6e9ef);margin-bottom:5px;"><span style="color:var(--okt,#2FBF8F);">30 de zile Pro gratuit</span> la fiecare cont nou</div>
|
||||||
|
<p style="font:400 14px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Începi direct cu tot ce are planul Pro — import API, categorisire automată și suport rapid. După 30 de zile treci automat pe Gratuit, fără plată și fără întreruperi.</p>
|
||||||
|
</div>
|
||||||
|
<button data-act="auth" data-tab="register" data-plan="pro" style="height:48px;padding:0 24px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;white-space:nowrap;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Începe gratuit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PRICING -->
|
<!-- PRICING -->
|
||||||
<div id="pret" style="padding:0 40px 80px;">
|
<div id="pret" style="padding:0 40px 80px;">
|
||||||
<div style="text-align:center;margin-bottom:44px;">
|
<div style="text-align:center;margin-bottom:44px;">
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.08em;text-transform:uppercase;margin-bottom:12px;">Preț</div>
|
|
||||||
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
<h2 style="font:700 34px var(--font-ui);letter-spacing:-.02em;margin:0 0 10px;color:var(--text,#e6e9ef);">Pentru un service mic, nu costă nimic</h2>
|
||||||
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Fiecare cont începe cu <strong style="color:var(--text,#e6e9ef);font-weight:600;">Pro gratuit 30 de zile</strong>. Apoi trece automat pe Gratuit — fără plată, dacă nu alegi alt plan. Fără card bancar.</p>
|
<p style="font:400 15px var(--font-ui);color:var(--sub,#8b93a7);margin:0;">Alege planul potrivit volumului tău. Poți schimba sau anula oricând.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:start;">
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 auto;align-items:stretch;">
|
||||||
<!-- Gratuit -->
|
<!-- Gratuit -->
|
||||||
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Testare și firme mici</div>
|
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
|
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Gratuit</div>
|
||||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">0 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Fără card bancar</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Până la 60 de trimiteri/lună</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Până la 60 de prestații/lună</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de prestații RAR (din mii)</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare manuală coloane, cu salvare</div>
|
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Trimiteri nelimitate</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, în 48h</div>
|
||||||
</div>
|
</div>
|
||||||
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
<button data-act="auth" data-tab="register" data-plan="free" style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Standard -->
|
<!-- Standard -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1.5px solid var(--accent,#2E74D6);border-radius:12px;padding:26px 24px;position:relative;display:flex;flex-direction:column;">
|
||||||
|
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:var(--accent,#2E74D6);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Popular</div>
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
|
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Standard</div>
|
||||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">39 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">49 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Volum nelimitat, fără API</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Trimiteri nelimitate</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Gratuit</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Prestații nelimitate</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport contact/email în 48h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--sub,#8b93a7);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--sub,#8b93a7)" stroke-width="1.8" style="flex-shrink:0;margin-top:1px;"><path d="M4 12h16"/></svg>Fără import API</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Import prin API</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 24h</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 14px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="standard">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pro -->
|
<!-- Pro -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;position:relative;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="position:absolute;top:-12px;left:20px;padding:4px 11px;border-radius:99px;background:color-mix(in srgb,#2FBF8F 90%,#000);color:#fff;font:700 10px var(--font-ui);letter-spacing:.04em;text-transform:uppercase;">Cel mai ales</div>
|
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
|
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Pro</div>
|
||||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">59 lei</span><span style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">/lună</span><span style="font:400 12px var(--font-ui);color:var(--mut,#5c6473);">* fără TVA</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Cu acces API</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Nelimitat + acces API</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Standard</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API + cheie API per cont</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport pe email în 24h</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare la operațiile incerte</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
<span style="display:none;"></span>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--mut,#5c6473);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--mut,#5c6473)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M5 12h14"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport email, maxim 8h</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="pro">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Premium -->
|
<!-- Premium -->
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;">
|
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:12px;padding:26px 24px;display:flex;flex-direction:column;">
|
||||||
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
<div style="font:700 17px var(--font-ui);color:var(--text,#e6e9ef);margin-bottom:8px;">Premium</div>
|
||||||
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 30px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
<div style="display:flex;align-items:baseline;gap:5px;margin-bottom:4px;"><span style="font:700 32px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">La cerere</span></div>
|
||||||
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
<div style="font:400 13px var(--font-ui);color:var(--sub,#8b93a7);margin-bottom:20px;">Soluție personalizată</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;">
|
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px;flex:1;">
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Tot din Pro</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Sugestii automate de operații RAR</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Opțiune de integrare în softul tău</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Mapare coloane cu salvare</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic și online</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Validare și anti-duplicat</div>
|
||||||
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Asistență și onboarding dedicate</div>
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Trimiteri nelimitate</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Import prin API</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Categorisire automată, cu confirmare</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Integrare în softul tău</div>
|
||||||
|
<div style="display:flex;gap:9px;font:400 13px/1.4 var(--font-ui);color:var(--text,#e6e9ef);"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><path d="M20 6L9 17l-5-5"/></svg>Suport telefonic + onboarding dedicat</div>
|
||||||
</div>
|
</div>
|
||||||
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
<button style="width:100%;height:46px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 14px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)" data-act="auth" data-tab="register" data-plan="premium">Creează cont gratuit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,10 +360,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
<div style="font:500 13px var(--font-ui);color:var(--accent,#2E74D6);letter-spacing:.1em;text-transform:uppercase;margin-bottom:14px;">Creează cont</div>
|
||||||
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
|
<h2 style="font:700 34px/1.15 var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Creează cont în 2 minute și declară azi la RAR</h2>
|
||||||
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit, fără card bancar. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
<p style="font:400 16px/1.6 var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 24px;">Te înregistrezi gratuit. Imediat poți încărca primul fișier sau conecta softul de service.</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
|
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Pro gratuit 30 de zile, apoi automat pe Gratuit</div>
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Fără card bancar la înscriere</div>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
|
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Conform Legii 142/2023 și OMTI 210/2024</div>
|
||||||
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
|
<div style="display:flex;gap:10px;align-items:center;font:400 15px var(--font-ui);color:var(--text,#e6e9ef);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#2FBF8F" stroke-width="2" style="flex-shrink:0;"><path d="M20 6L9 17l-5-5"/></svg>Datele cu caracter personal criptate (GDPR)</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,7 +378,7 @@
|
|||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">CUI</span><input type="text" name="cui" required placeholder="RO12345678" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-mono);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Email</span><input type="email" name="email" required placeholder="nume@service.ro" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
<label style="display:block;margin-bottom:14px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Parolă</span><input type="password" name="parola" required minlength="10" placeholder="Minim 10 caractere" style="width:100%;height:44px;padding:0 12px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;" /></label>
|
||||||
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 39 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
<label style="display:block;margin-bottom:16px;"><span style="display:block;margin-bottom:6px;font:500 13px var(--font-ui);color:var(--sub,#8b93a7);">Pachet ales</span><select id="plan-select" name="plan" style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line,#262b36);border-radius:6px;background:var(--card2,#0f1218);color:var(--text,#e6e9ef);font:400 14px var(--font-ui);outline:none;cursor:pointer;"><option value="free" selected>Gratuit — 0 lei/lună</option><option value="standard">Standard — 49 lei/lună</option><option value="pro">Pro — 59 lei/lună</option><option value="premium">Premium — la cerere</option></select></label>
|
||||||
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
<label style="display:flex;gap:9px;align-items:flex-start;margin-bottom:18px;font:400 13px/1.5 var(--font-ui);color:var(--sub,#8b93a7);cursor:pointer;"><input type="checkbox" name="consent" value="1" required style="margin-top:2px;accent-color:var(--accent,#2E74D6);width:16px;height:16px;flex-shrink:0;" />Sunt de acord cu Termenii și cu prelucrarea datelor conform politicii de confidențialitate (GDPR).</label>
|
||||||
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
<button type="submit" class="btn-green" style="width:100%;height:48px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;">Creează cont gratuit</button>
|
||||||
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
<div style="text-align:center;margin-top:14px;font:400 13px var(--font-ui);color:var(--sub,#8b93a7);">Ai deja cont? <a data-act="tab" data-tab="login" style="color:var(--accent,#2E74D6);font-weight:500;cursor:pointer;">Autentifică-te</a></div>
|
||||||
@@ -369,18 +395,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FINAL CTA -->
|
|
||||||
<div style="padding:0 40px 80px;">
|
|
||||||
<div style="background:var(--card,#181c24);border:1px solid var(--line,#262b36);border-radius:16px;padding:56px 40px;text-align:center;">
|
|
||||||
<h2 style="font:700 36px var(--font-ui);letter-spacing:-.02em;margin:0 0 14px;color:var(--text,#e6e9ef);">Începe să declari la RAR în câteva minute</h2>
|
|
||||||
<p style="font:400 16px var(--font-ui);color:var(--sub,#8b93a7);margin:0 0 28px;">Gratuit până la 60 de prezentări pe lună. Fără card bancar.</p>
|
|
||||||
<div style="display:flex;gap:12px;justify-content:center;">
|
|
||||||
<button data-act="auth" data-tab="register" style="height:50px;padding:0 28px;border-radius:6px;background:#1F9D5C;border:1px solid #1F9D5C;color:#fff;font:700 15px var(--font-ui);cursor:pointer;transition:transform .2s cubic-bezier(.2,.8,.2,1), background .2s ease, box-shadow .2s ease;" style-hover="background:#16864a;border-color:#16864a;transform:translateY(-1px)">Creează cont gratuit</button>
|
|
||||||
<button data-act="auth" data-tab="login" style="height:50px;padding:0 24px;border-radius:6px;background:transparent;border:1px solid var(--line,#262b36);color:var(--text,#e6e9ef);font:500 15px var(--font-ui);cursor:pointer;transition:transform .18s ease, background .18s ease, border-color .18s ease;" style-hover="background:#1F9D5C;border-color:#1F9D5C;color:#fff;transform:translateY(-1px)">Autentificare</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
<div style="border-top:1px solid var(--line,#262b36);padding:36px 40px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
||||||
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
<div style="font:700 18px var(--font-ui);letter-spacing:-.02em;color:var(--text,#e6e9ef);">ROM<span style="color:var(--accent,#2E74D6);">FAST</span></div>
|
||||||
@@ -415,7 +429,7 @@
|
|||||||
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
var hMonth=(p*minPer)/60,leiMonth=hMonth*r;
|
||||||
document.getElementById('out-pres').textContent=p;
|
document.getElementById('out-pres').textContent=p;
|
||||||
document.getElementById('out-rate').textContent=r;
|
document.getElementById('out-rate').textContent=r;
|
||||||
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf1.format(hMonth),leiYear:nf.format(Math.round(leiMonth*12)),days:nf1.format((hMonth*12)/8)};
|
var map={leiMonth:nf.format(Math.round(leiMonth)),hMonth:nf.format(Math.round(hMonth)),leiYear:nf.format(Math.round(leiMonth*12)),days:nf.format(Math.round((hMonth*12)/8))};
|
||||||
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
Object.keys(map).forEach(function(k){document.querySelectorAll('[data-calc="'+k+'"]').forEach(function(n){n.textContent=map[k];});});
|
||||||
}
|
}
|
||||||
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
if(pres&&rate){pres.addEventListener('input',recalc);rate.addEventListener('input',recalc);recalc();}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Autentificare — ROMFAST AUTOPASS{% endblock %}
|
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
|
||||||
Stanga: logo + tagline + puncte de incredere.
|
Stanga: logo + tagline + puncte de incredere.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<select name="plan"
|
<select name="plan"
|
||||||
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
style="width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card2);color:var(--ink);font:400 14px var(--font-ui);outline:none;cursor:pointer;">
|
||||||
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
<option value="free"{% if not plan or plan == 'free' %} selected{% endif %}>Gratuit — 0 lei/lună</option>
|
||||||
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 39 lei/lună</option>
|
<option value="standard"{% if plan == 'standard' %} selected{% endif %}>Standard — 49 lei/lună</option>
|
||||||
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
<option value="pro"{% if plan == 'pro' %} selected{% endif %}>Pro — 59 lei/lună</option>
|
||||||
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
<option value="premium"{% if plan == 'premium' %} selected{% endif %}>Premium — la cerere</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from ..observ import log_event, set_source
|
|||||||
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
|
||||||
from ..payload import build_rar_payload
|
from ..payload import build_rar_payload
|
||||||
from ..reconcile import match_finalizata
|
from ..reconcile import match_finalizata
|
||||||
from ..rar_client import RarAuthError, RarClient, RarError
|
from ..rar_client import RarAuthError, RarClient, RarError, base_url_pentru_env
|
||||||
|
|
||||||
_running = True
|
_running = True
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ def claim_one(conn) -> dict | None:
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
|
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc, s.rar_env "
|
||||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||||
"WHERE s.status='queued' "
|
"WHERE s.status='queued' "
|
||||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||||
@@ -189,6 +189,7 @@ def claim_one(conn) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"account_id": row["account_id"] if row["account_id"] is not None else DEFAULT_ACCOUNT_ID,
|
"account_id": row["account_id"] if row["account_id"] is not None else DEFAULT_ACCOUNT_ID,
|
||||||
|
"rar_env": row["rar_env"],
|
||||||
"creds_enc": row["rar_creds_enc"],
|
"creds_enc": row["rar_creds_enc"],
|
||||||
"content": json.loads(row["payload_json"]),
|
"content": json.loads(row["payload_json"]),
|
||||||
}
|
}
|
||||||
@@ -281,11 +282,13 @@ def _handle_transient(conn, settings: Settings, rar: RarClient, token: str, sid:
|
|||||||
return "requeued"
|
return "requeued"
|
||||||
|
|
||||||
|
|
||||||
def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, account_id: int | None = None) -> int:
|
def recover_orphans(conn, settings: Settings, rar: RarClient, token: str,
|
||||||
|
account_id: int | None = None, rar_env: str | None = None) -> int:
|
||||||
"""Randuri 'sending' mai vechi de lease (worker mort mid-POST). Reconciliaza; altfel requeue.
|
"""Randuri 'sending' mai vechi de lease (worker mort mid-POST). Reconciliaza; altfel requeue.
|
||||||
|
|
||||||
`account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti
|
`account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti.
|
||||||
(compat teste / single-account).
|
`rar_env` filtreaza la orfanii unui mediu (1b/E6): orfanii prod contra endpoint prod,
|
||||||
|
NU contra test — altfel no-match -> re-POST prod = DUPLICAT real ireversibil.
|
||||||
"""
|
"""
|
||||||
# Cutoff calculat SQLite-side, in ACELASI format ca sending_since (scris cu
|
# Cutoff calculat SQLite-side, in ACELASI format ca sending_since (scris cu
|
||||||
# datetime('now') in claim_one -> 'YYYY-MM-DD HH:MM:SS', cu spatiu). Daca am
|
# datetime('now') in claim_one -> 'YYYY-MM-DD HH:MM:SS', cu spatiu). Daca am
|
||||||
@@ -293,18 +296,18 @@ def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, accoun
|
|||||||
# orice rand 'sending' sa para mereu <= cutoff -> lease-ul de 120s ignorat,
|
# orice rand 'sending' sa para mereu <= cutoff -> lease-ul de 120s ignorat,
|
||||||
# iar fiecare rand proaspat revendicat ar fi tratat instant ca orfan.
|
# iar fiecare rand proaspat revendicat ar fi tratat instant ca orfan.
|
||||||
lease = f"-{int(settings.worker_sending_lease_s)} seconds"
|
lease = f"-{int(settings.worker_sending_lease_s)} seconds"
|
||||||
|
base_sql = (
|
||||||
|
"SELECT id, payload_json FROM submissions WHERE status='sending' "
|
||||||
|
"AND (sending_since IS NULL OR sending_since <= datetime('now', ?))"
|
||||||
|
)
|
||||||
|
params: list = [lease]
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
orphans = conn.execute(
|
base_sql += " AND account_id=?"
|
||||||
"SELECT id, payload_json FROM submissions WHERE status='sending' "
|
params.append(account_id)
|
||||||
"AND (sending_since IS NULL OR sending_since <= datetime('now', ?)) AND account_id=?",
|
if rar_env is not None:
|
||||||
(lease, account_id),
|
base_sql += " AND rar_env=?"
|
||||||
).fetchall()
|
params.append(rar_env)
|
||||||
else:
|
orphans = conn.execute(base_sql, params).fetchall()
|
||||||
orphans = conn.execute(
|
|
||||||
"SELECT id, payload_json FROM submissions WHERE status='sending' "
|
|
||||||
"AND (sending_since IS NULL OR sending_since <= datetime('now', ?))",
|
|
||||||
(lease,),
|
|
||||||
).fetchall()
|
|
||||||
recovered = 0
|
recovered = 0
|
||||||
for row in orphans:
|
for row in orphans:
|
||||||
sid = row["id"]
|
sid = row["id"]
|
||||||
@@ -337,25 +340,26 @@ def _refresh_nomenclator(conn, rar: RarClient, token: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class AccountSessions:
|
class AccountSessions:
|
||||||
"""Sesiuni RAR per cont: login lazy cu creds din submission + cache JWT (30h).
|
"""Sesiuni RAR per (cont, env): login lazy cu creds din submission + cache JWT (30h).
|
||||||
|
|
||||||
La primul login reusit pentru un cont sterge creds-urile criptate ale contului
|
Cheia = (account_id, rar_env): test si prod sunt sisteme RAR separate cu JWT separate.
|
||||||
(token-ul in memorie acopera restul). Pe 401 mid-sesiune se invalideaza sesiunea
|
La primul login reusit pentru (cont, env) sterge creds-urile efemere scoped pe acel env.
|
||||||
-> re-login la urmatorul submission cu creds.
|
Pe 401 mid-sesiune se invalideaza sesiunea -> re-login la urmatorul submission cu creds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self._sessions: dict[int, tuple[RarClient, str]] = {}
|
self._sessions: dict[tuple[int, str], tuple[RarClient, str]] = {}
|
||||||
|
|
||||||
def get_token(self, conn, account_id: int, creds: dict | None) -> str | None:
|
def get_token(self, conn, account_id: int, creds: dict | None, rar_env: str = "test") -> str | None:
|
||||||
"""Token valid pentru cont. Login daca lipseste din cache si avem creds; altfel None."""
|
"""Token valid pentru (cont, env). Login daca lipseste din cache si avem creds; altfel None."""
|
||||||
sess = self._sessions.get(account_id)
|
key = (account_id, rar_env)
|
||||||
|
sess = self._sessions.get(key)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
return sess[1]
|
return sess[1]
|
||||||
if not creds or not creds.get("email") or not creds.get("password"):
|
if not creds or not creds.get("email") or not creds.get("password"):
|
||||||
return None
|
return None
|
||||||
rar = RarClient(self.settings)
|
rar = RarClient(self.settings, base_url=base_url_pentru_env(self.settings, rar_env))
|
||||||
try:
|
try:
|
||||||
token = rar.login(creds["email"], creds["password"])
|
token = rar.login(creds["email"], creds["password"])
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
@@ -363,40 +367,52 @@ class AccountSessions:
|
|||||||
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
# Login esuat (401) — FARA email/parola (doar codul HTTP + contul).
|
||||||
log_event("rar_login", nivel="WARNING", account_id=account_id,
|
log_event("rar_login", nivel="WARNING", account_id=account_id,
|
||||||
cod="RAR_CREDS_INVALIDE",
|
cod="RAR_CREDS_INVALIDE",
|
||||||
mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}",
|
mesaj=f"login RAR esuat (cont {account_id}, env={rar_env}): {exc.status_code or 401}",
|
||||||
context={"rezultat": "esuat", "http": exc.status_code or 401},
|
context={"rezultat": "esuat", "http": exc.status_code or 401},
|
||||||
conn=conn, sursa="worker")
|
conn=conn, sursa="worker")
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
rar.close()
|
rar.close()
|
||||||
raise
|
raise
|
||||||
self._sessions[account_id] = (rar, token)
|
self._sessions[key] = (rar, token)
|
||||||
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})")
|
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id}, env={rar_env})")
|
||||||
# Login reusit (fara email/parola in clar — context curat).
|
# Login reusit (fara email/parola in clar — context curat).
|
||||||
log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})",
|
log_event("rar_login", account_id=account_id,
|
||||||
|
mesaj=f"login RAR ok (cont {account_id}, env={rar_env})",
|
||||||
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
|
context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker")
|
||||||
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
|
# Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge.
|
||||||
# GATE PURJARE: sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
|
# GATE PURJARE: sterge DOAR submissions.rar_creds_enc (WHERE account_id=? AND rar_env=?),
|
||||||
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
|
# NU accounts.rar_creds_{env}_enc. Scoped pe env: login test NU sterge creds prod (E1/1a).
|
||||||
# Canal API pur: purjarea e identica cu Treapta 1 (neatinsa).
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET rar_creds_enc=NULL WHERE account_id=? AND rar_creds_enc IS NOT NULL",
|
"UPDATE submissions SET rar_creds_enc=NULL "
|
||||||
(account_id,),
|
"WHERE account_id=? AND rar_env=? AND rar_creds_enc IS NOT NULL",
|
||||||
|
(account_id, rar_env),
|
||||||
)
|
)
|
||||||
# Nomenclator live (autoritativ) la fiecare login proaspat.
|
# Nomenclator live (autoritativ) la fiecare login proaspat.
|
||||||
|
# Nota (1e): nomenclatorul e presupus identic intre medii (aceleasi 18 coduri RAR);
|
||||||
|
# daca diverge in viitor, scoparea per-env a tabelei nomenclator_rar e out of scope acum.
|
||||||
_refresh_nomenclator(conn, rar, token)
|
_refresh_nomenclator(conn, rar, token)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def rar(self, account_id: int) -> RarClient:
|
def rar(self, account_id: int, rar_env: str = "test") -> RarClient:
|
||||||
return self._sessions[account_id][0]
|
return self._sessions[(account_id, rar_env)][0]
|
||||||
|
|
||||||
def active(self) -> list[tuple[int, RarClient, str]]:
|
def active(self) -> list[tuple[int, str, RarClient, str]]:
|
||||||
return [(acct, rar, tok) for acct, (rar, tok) in self._sessions.items()]
|
"""Sesiunile active: lista de (account_id, rar_env, RarClient, token)."""
|
||||||
|
return [(acct, env, rar, tok) for (acct, env), (rar, tok) in self._sessions.items()]
|
||||||
|
|
||||||
def invalidate(self, account_id: int) -> None:
|
def invalidate(self, account_id: int, rar_env: str | None = None) -> None:
|
||||||
sess = self._sessions.pop(account_id, None)
|
"""Invalideaza sesiunea (cont, env). rar_env=None invalideaza TOATE sesiunile contului."""
|
||||||
|
if rar_env is not None:
|
||||||
|
sess = self._sessions.pop((account_id, rar_env), None)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
sess[0].close()
|
sess[0].close()
|
||||||
|
else:
|
||||||
|
# Invalideaza toate mediile pentru acest cont.
|
||||||
|
keys_to_remove = [k for k in self._sessions if k[0] == account_id]
|
||||||
|
for k in keys_to_remove:
|
||||||
|
sess = self._sessions.pop(k)
|
||||||
|
sess[0].close()
|
||||||
|
|
||||||
def close_all(self) -> None:
|
def close_all(self) -> None:
|
||||||
for rar, _tok in self._sessions.values():
|
for rar, _tok in self._sessions.values():
|
||||||
@@ -414,33 +430,39 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _creds_from_account(conn, account_id: int) -> dict | None:
|
def _creds_from_account(conn, account_id: int, rar_env: str = "test") -> dict | None:
|
||||||
"""Fallback: crede RAR durabile per-cont din accounts.rar_creds_enc.
|
"""Creds RAR durabile per-cont din slotul per-env, cu fallback la coloana legacy.
|
||||||
|
|
||||||
Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login
|
Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env). Fallback la
|
||||||
sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand.
|
accounts.rar_creds_enc (legacy, back-compat inainte de US-013 care dropa coloana veche).
|
||||||
"""
|
"""
|
||||||
|
env_slot = f"rar_creds_{rar_env}_enc"
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (account_id,)
|
f"SELECT {env_slot}, rar_creds_enc FROM accounts WHERE id=?", (account_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row["rar_creds_enc"]:
|
if not row:
|
||||||
return decrypt_creds(row["rar_creds_enc"])
|
|
||||||
return None
|
return None
|
||||||
|
enc = row[env_slot] or row["rar_creds_enc"] # per-env intai, legacy fallback
|
||||||
|
return decrypt_creds(enc) if enc else None
|
||||||
|
|
||||||
|
|
||||||
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
|
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
|
||||||
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
|
||||||
|
|
||||||
|
Ancora M2: cauta in slotul per-env al mediului `settings.rar_env` (ancora globala).
|
||||||
|
Fallback la coloana legacy `rar_creds_enc` (back-compat inainte de US-013).
|
||||||
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
|
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
|
||||||
`start.sh both` genereaza o cheie efemera noua la fiecare pornire, deci creds-urile
|
`start.sh both` genereaza o cheie efemera noua la fiecare pornire.
|
||||||
durabile criptate sub cheia veche dau decrypt -> None. Fallback la creds <test>.
|
|
||||||
"""
|
"""
|
||||||
|
env_slot = f"rar_creds_{settings.rar_env}_enc"
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, rar_creds_enc FROM accounts "
|
f"SELECT id, {env_slot}, rar_creds_enc FROM accounts ORDER BY id"
|
||||||
"WHERE rar_creds_enc IS NOT NULL ORDER BY id"
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
creds = decrypt_creds(row["rar_creds_enc"])
|
enc = row[env_slot] or row["rar_creds_enc"] # per-env intai, legacy fallback
|
||||||
|
if not enc:
|
||||||
|
continue
|
||||||
|
creds = decrypt_creds(enc)
|
||||||
if creds and creds.get("email") and creds.get("password"):
|
if creds and creds.get("email") and creds.get("password"):
|
||||||
return row["id"], creds
|
return row["id"], creds
|
||||||
if settings.worker_use_test_creds:
|
if settings.worker_use_test_creds:
|
||||||
@@ -478,9 +500,9 @@ def _maybe_keepalive(conn, settings: Settings, sessions: "AccountSessions", stat
|
|||||||
account_id, creds = _keepalive_target(conn, settings)
|
account_id, creds = _keepalive_target(conn, settings)
|
||||||
if account_id is None or not creds:
|
if account_id is None or not creds:
|
||||||
return # niciun cont cu creds durabile — nimic de sondat
|
return # niciun cont cu creds durabile — nimic de sondat
|
||||||
sessions.invalidate(account_id) # forteaza login real, nu token din cache
|
sessions.invalidate(account_id, settings.rar_env) # forteaza login real pt env-ul global
|
||||||
try:
|
try:
|
||||||
sessions.get_token(conn, account_id, creds) # reimprospateaza last_rar_login_ok la succes
|
sessions.get_token(conn, account_id, creds, settings.rar_env) # reimprospateaza last_rar_login_ok la succes
|
||||||
except RarAuthError:
|
except RarAuthError:
|
||||||
pass # creds invalide — deja logat in get_token (WARNING)
|
pass # creds invalide — deja logat in get_token (WARNING)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -527,8 +549,8 @@ def run() -> int:
|
|||||||
claimed = claim_one(conn)
|
claimed = claim_one(conn)
|
||||||
if claimed is None:
|
if claimed is None:
|
||||||
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
|
||||||
for acct, rar, tok in sessions.active():
|
for acct, env, rar, tok in sessions.active():
|
||||||
recover_orphans(conn, settings, rar, tok, account_id=acct)
|
recover_orphans(conn, settings, rar, tok, account_id=acct, rar_env=env)
|
||||||
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
|
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
|
||||||
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
|
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
|
||||||
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
|
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
|
||||||
@@ -537,18 +559,20 @@ def run() -> int:
|
|||||||
|
|
||||||
sid = claimed["id"]
|
sid = claimed["id"]
|
||||||
account_id = claimed["account_id"]
|
account_id = claimed["account_id"]
|
||||||
|
# Mediul tinta al trimiterii (per-submission, cu fallback la ancora globala).
|
||||||
|
rar_env = claimed.get("rar_env") or settings.rar_env
|
||||||
# Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
|
# Randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima trimitere
|
||||||
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
|
# a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea RAR
|
||||||
# cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea,
|
# cache-uita (per env) ca un JWT vechi (30h) din parola GRESITA sa nu trimita
|
||||||
# ignorand corectia. Re-login imediat cu creds-urile noi.
|
# cu ea, ignorand corectia. Re-login imediat cu creds-urile noi.
|
||||||
if claimed.get("creds_enc"):
|
if claimed.get("creds_enc"):
|
||||||
sessions.invalidate(account_id)
|
sessions.invalidate(account_id, rar_env)
|
||||||
# Incearca creds din submission (canal API efemer), cu fallback la
|
# Incearca creds din submission (canal API efemer), cu fallback la
|
||||||
# accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher.
|
# accounts.rar_creds_{rar_env}_enc (canal web durabil, per-env).
|
||||||
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id)
|
creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id, rar_env)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = sessions.get_token(conn, account_id, creds)
|
token = sessions.get_token(conn, account_id, creds, rar_env)
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
# Creds gresite (login 401): NU se face retry.
|
# Creds gresite (login 401): NU se face retry.
|
||||||
mark(conn, sid, "error", rar_status_code=401,
|
mark(conn, sid, "error", rar_status_code=401,
|
||||||
@@ -565,9 +589,9 @@ def run() -> int:
|
|||||||
requeue_with_backoff(conn, settings, sid, reason="creds RAR indisponibile (astept re-trimitere)")
|
requeue_with_backoff(conn, settings, sid, reason="creds RAR indisponibile (astept re-trimitere)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rar = sessions.rar(account_id)
|
rar = sessions.rar(account_id, rar_env)
|
||||||
# Recupereaza orfanii contului inainte de trimitere (acelasi token).
|
# Recupereaza orfanii contului + env-ului inainte de trimitere (acelasi token).
|
||||||
recover_orphans(conn, settings, rar, token, account_id=account_id)
|
recover_orphans(conn, settings, rar, token, account_id=account_id, rar_env=rar_env)
|
||||||
# Guard: recover_orphans putea atinge chiar randul tocmai revendicat
|
# Guard: recover_orphans putea atinge chiar randul tocmai revendicat
|
||||||
# (reconciliat 'sent' sau requeue 'queued'). Daca nu mai e 'sending',
|
# (reconciliat 'sent' sau requeue 'queued'). Daca nu mai e 'sending',
|
||||||
# NU mai face POST — altfel s-ar crea un duplicat la RAR.
|
# NU mai face POST — altfel s-ar crea un duplicat la RAR.
|
||||||
@@ -579,9 +603,9 @@ def run() -> int:
|
|||||||
try:
|
try:
|
||||||
process_one(conn, settings, rar, token, claimed)
|
process_one(conn, settings, rar, token, claimed)
|
||||||
except RarAuthError as exc:
|
except RarAuthError as exc:
|
||||||
# Token expirat mid-sesiune: invalideaza sesiunea, re-pune randul.
|
# Token expirat mid-sesiune: invalideaza sesiunea (per env), re-pune randul.
|
||||||
print(f"[worker] cont {account_id} token expirat: {exc}; re-login data viitoare", flush=True)
|
print(f"[worker] cont {account_id} env={rar_env} token expirat: {exc}; re-login data viitoare", flush=True)
|
||||||
sessions.invalidate(account_id)
|
sessions.invalidate(account_id, rar_env)
|
||||||
requeue_with_backoff(conn, settings, sid, reason="token RAR expirat")
|
requeue_with_backoff(conn, settings, sid, reason="token RAR expirat")
|
||||||
|
|
||||||
except Exception as exc: # noqa: BLE001 — loop top-level: o eroare punctuala nu opreste worker-ul
|
except Exception as exc: # noqa: BLE001 — loop top-level: o eroare punctuala nu opreste worker-ul
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: prod
|
# Override din environment (Dokploy) pentru staging; default = prod.
|
||||||
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
|
||||||
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
||||||
TZ: ${TZ:-Europe/Bucharest}
|
TZ: ${TZ:-Europe/Bucharest}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
@@ -35,10 +36,11 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: test
|
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||||
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
||||||
AUTOPASS_WORKER_SEND_ENABLED: "true"
|
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
||||||
|
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
478
docs/prd/prd-5.20-target-rar-test-productie.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260629-184940.md -->
|
||||||
|
# PRD 5.20 — Medii RAR per cont (Testare / Productie): activare, credentiale, selectie per trimitere
|
||||||
|
|
||||||
|
**Stare**: aprobat
|
||||||
|
|
||||||
|
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||||
|
> Stare: `draft -> aprobat -> in-executie -> verify-pass -> inchis`.
|
||||||
|
|
||||||
|
## 1. Obiectiv
|
||||||
|
|
||||||
|
Trateaza **Testare** si **Productie** ca doua medii RAR configurabile **per cont**. Fiecare mediu are, independent:
|
||||||
|
o **bifa de activare** si un **set propriu de credentiale**. Un mediu e *disponibil* pentru trimitere doar daca e
|
||||||
|
activat SI are credentiale. Din disponibilitate decurge tot UX-ul: cand un singur mediu e disponibil totul merge
|
||||||
|
acolo (fara selector); cand ambele sunt disponibile, apare selector la import + toggle in statusbar + alegere in
|
||||||
|
API. Trimiterile arata mereu un **badge** cu mediul tinta. Scop: clientul declara real pe Productie, iar cine are
|
||||||
|
si cont de test RAR isi poate testa integrarea pe Testare — fara redeploy si fara variabila globala de mediu.
|
||||||
|
|
||||||
|
**Premisa verificata (2026-06-29, doua seturi reale)**: test si prod sunt sisteme RAR **complet separate**; un set
|
||||||
|
de credentiale se autentifica pe **exact unul** (creds dev: test 200 / prod 401; creds client real: test 401 /
|
||||||
|
prod 200). Deci 2 seturi de creds per cont; un cont prod-only NU poate trimite la test fara cont de test emis de RAR.
|
||||||
|
Detaliu memorat: vezi memoria de proiect "rar-test-prod-creds-separate".
|
||||||
|
|
||||||
|
## 2. Non-Goals (anti scope-creep)
|
||||||
|
|
||||||
|
- NU eliminam `AUTOPASS_RAR_ENV` global: ramane **ancora de migrare** + fallback pentru actiuni de sistem fara cont
|
||||||
|
(ex. keepalive login). Per-submission are precedenta cand exista.
|
||||||
|
- NU configuram base_url-uri din UI (raman in `config.py`); NU adaugam un al treilea mediu.
|
||||||
|
- NU gating pe plan/tier pentru Productie (decizie user: liber). „Guard-ul" e: Productie e tinta doar daca e
|
||||||
|
activata + are creds, plus o confirmare unica la activarea Productie (constientizare L.142), NU per trimitere.
|
||||||
|
- NU schimbam masina de stari, backoff-ul, sau payload-ul `postPrezentare`.
|
||||||
|
- NU migram automat credentiale de prod ale clientilor — ei le introduc; migrarea doar muta creds-ul existent in
|
||||||
|
slotul mediului sub care contul opera efectiv.
|
||||||
|
|
||||||
|
## 3. Cerinte transversale (reguli de derivare)
|
||||||
|
|
||||||
|
- **REQ-DISP**: `medii_disponibile(cont)` = mediile din {test, prod} cu `enabled=1` SI creds prezente. Sursa unica
|
||||||
|
de adevar pentru vizibilitatea selector/toggle si pentru validarea unei tinte cerute.
|
||||||
|
- **REQ-VIZ**: selector la import + toggle in statusbar apar DOAR cand `len(medii_disponibile) >= 2`. La 1 mediu,
|
||||||
|
tinta e implicita (acel mediu), fara selector. La 0, trimiterea e blocata cu mesaj „configureaza credentiale RAR".
|
||||||
|
- **REQ-BADGE**: orice trimitere afiseaza badge Test/Productie (chiar si la 1 mediu — claritate ca declari real).
|
||||||
|
- **REQ-DEFAULT**: `rar_env_default(cont)` e mereu unul din mediile disponibile; cont client nou = `prod`. Daca
|
||||||
|
default-ul nu mai e disponibil (mediu dezactivat), cade pe singurul disponibil; daca 0 disponibile -> nicio tinta.
|
||||||
|
- **REQ-CONF**: trimiterea pe Productie nu cere confirmare per-rand; constientizarea vine din badge + o confirmare
|
||||||
|
UNICA la activarea mediului Productie in configurare.
|
||||||
|
|
||||||
|
## 4. Stories atomice
|
||||||
|
|
||||||
|
> Backend + UI pentru acelasi comportament = stories separate. `Fisiere` + `Depinde de` complete.
|
||||||
|
|
||||||
|
### US-001: Schema — medii per cont (activare + creds) + env pe submission
|
||||||
|
**Ca** sistem **vreau** sa stochez per cont activarea si credentialele fiecarui mediu, default-ul, si env-ul tinta
|
||||||
|
pe fiecare submission **pentru ca** test si prod sunt sisteme separate cu credentiale separate.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare idempotenta), `tests/test_schema_migrate.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_schema_migrate.py` — `test_coloane_medii_pe_cont`,
|
||||||
|
`test_default_client_prod_on_test_off`, `test_migrare_creds_in_slotul_env_global`, `test_submissions_rar_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `accounts`: `rar_test_enabled INTEGER NOT NULL DEFAULT 0`, `rar_prod_enabled INTEGER NOT NULL DEFAULT 1`
|
||||||
|
(ambele CHECK IN (0,1)); `rar_creds_test_enc TEXT`, `rar_creds_prod_enc TEXT`;
|
||||||
|
`rar_env_default TEXT NOT NULL DEFAULT 'prod' CHECK (rar_env_default IN ('test','prod'))`
|
||||||
|
- [ ] `submissions.rar_env TEXT NOT NULL DEFAULT 'test' CHECK (rar_env IN ('test','prod'))`
|
||||||
|
- [ ] **Migrare existenti (NU presupune env-ul)**: `rar_creds_enc` -> slotul `AUTOPASS_RAR_ENV` global de la
|
||||||
|
migrare; seteaza `enabled=1` DOAR pe mediul cu creds; `rar_env_default` = acel mediu. Conturi fara creds:
|
||||||
|
raman pe default-urile coloanei (prod on / test off). Coloana veche RAMANE acum (dropul e in US-013, dupa
|
||||||
|
ce toate citirile trec pe per-env)
|
||||||
|
- [ ] **(AUTO-FIX G — CRITIC, amendament AC) Backfill `submissions.rar_env` EXISTENT din `AUTOPASS_RAR_ENV`
|
||||||
|
global**, NU lasa pe `DEFAULT 'test'`. Un rand prod pre-migrare etichetat 'test' -> US-006 reconciliaza
|
||||||
|
contra endpoint TEST -> no-match -> re-send prod = DUPLICAT REAL IREVERSIBIL. `DEFAULT 'test'` ramane doar
|
||||||
|
plasa pentru randuri net-noi (fiecare INSERT din US-004/005/009 seteaza `rar_env` explicit)
|
||||||
|
- [ ] **(AUTO-FIX E4/3) Recompute `idempotency_key` pentru randurile existente** la forma env-aware
|
||||||
|
(`build_key(account_id, canon, rar_env)` cu `rar_env`-ul backfill-at), ca lookup-urile de dedup (API +
|
||||||
|
import) sa nu rateze randuri legacy -> altfel re-POST = duplicat
|
||||||
|
- [ ] `test_submissions_rar_env` asserteaza ca un rand PRE-migrare ajunge cu env-ul global (NU 'test') si
|
||||||
|
reconciliaza contra endpointului corect
|
||||||
|
- [ ] migrare idempotenta pe DB existent, fara pierdere de date
|
||||||
|
- [ ] `python3 -m pytest tests/test_schema_migrate.py -q` PASS
|
||||||
|
- **Verificare E2E**: DB pre-migrare cu `AUTOPASS_RAR_ENV=test` -> creds aterizeaza in `rar_creds_test_enc`,
|
||||||
|
`rar_test_enabled=1`, `rar_env_default='test'`.
|
||||||
|
|
||||||
|
### US-002: Logica de disponibilitate si default efectiv
|
||||||
|
**Ca** sistem **vreau** un helper unic care intoarce mediile disponibile si default-ul efectiv al unui cont
|
||||||
|
**pentru ca** vizibilitatea UI, API-ul si worker-ul sa decida identic (REQ-DISP/REQ-DEFAULT).
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/rar_env.py` (nou) sau `app/mapping.py`, `tests/test_rar_env_disponibil.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_rar_env_disponibil.py` — `test_doar_prod_cu_creds`, `test_ambele`,
|
||||||
|
`test_zero_cand_lipsesc_creds`, `test_default_cade_pe_singurul_disponibil`, `test_enabled_fara_creds_nu_e_disponibil`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `medii_disponibile(account) -> list[str]` (subset din ['test','prod']) = enabled AND creds prezente
|
||||||
|
- [ ] `rar_env_efectiv(account) -> 'test'|'prod'|None` aplica REQ-DEFAULT
|
||||||
|
- [ ] `python3 -m pytest tests/test_rar_env_disponibil.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-003: Idempotenta include rar_env
|
||||||
|
**Ca** sistem **vreau** ca `build_key` sa incorporeze `rar_env` **pentru ca** aceeasi prezentare la test si apoi
|
||||||
|
la prod sunt doua trimiteri reale distincte, nu un duplicat.
|
||||||
|
|
||||||
|
- **Depinde de**: —
|
||||||
|
- **Fisiere**: `app/idempotency.py`, `tests/test_idempotency.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_idempotency.py` — `test_key_difera_intre_test_si_prod`, `test_key_stabil_pe_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] `build_key(account_id, canon, rar_env)` -> chei diferite test vs prod pe acelasi continut; stabil pe re-apel
|
||||||
|
- [ ] toate apelurile (`router.py`, `import_router.py`) trec env-ul rezolvat
|
||||||
|
- [ ] `python3 -m pytest tests/test_idempotency.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-004: Rezolvare tinta la ingestie (cerere > default cont) + respinge tinta indisponibila
|
||||||
|
**Ca** sistem **vreau** sa decid env-ul unui submission si sa resping tintele indisponibile **pentru ca** o tinta
|
||||||
|
fara mediu activ/creds nu trebuie sa intre in coada.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002
|
||||||
|
- **Fisiere**: `app/validation.py`, `app/mapping.py`, `tests/test_rar_env_resolve.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_rar_env_resolve.py` — `test_cerere_castiga`, `test_fallback_default_cont`,
|
||||||
|
`test_tinta_indisponibila_respinsa`, `test_valoare_invalida`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] precedenta: valoare ceruta (daca e in `medii_disponibile`) > `rar_env_efectiv(cont)`
|
||||||
|
- [ ] tinta ceruta dar indisponibila -> eroare clara („mediul X nu e activat / fara credentiale"), fara enqueue
|
||||||
|
- [ ] valoare invalida (≠ test/prod) -> eroare de validare, fara fallback silentios
|
||||||
|
- [ ] `python3 -m pytest tests/test_rar_env_resolve.py -q` PASS
|
||||||
|
- **Verificare E2E**: —
|
||||||
|
|
||||||
|
### US-005: API — camp `rar_target` pe POST /v1/prezentari si /valideaza
|
||||||
|
**Ca** integrator ROAAUTO **vreau** sa pot preciza `rar_target`, cu default = default-ul contului meu **pentru ca**
|
||||||
|
sa aleg unde declar fara sa stiu env-ul global.
|
||||||
|
|
||||||
|
- **Depinde de**: US-003, US-004
|
||||||
|
- **Fisiere**: `app/api/v1/router.py`, `app/models.py`, `tests/test_api_rar_target.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_api_rar_target.py` — `test_default_din_cont_cand_lipseste`,
|
||||||
|
`test_target_explicit`, `test_target_indisponibil_respins`, `test_get_ecou_rar_env`, `test_valoare_invalida_422`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] camp optional `rar_target: "test"|"prod"` pe `POST /v1/prezentari` si `/valideaza`
|
||||||
|
- [ ] absent -> `rar_env_efectiv(cont)` (pt client prod-only = `prod`)
|
||||||
|
- [ ] tinta indisponibila -> raspuns clar, fara enqueue; `SubmissionResult` + GET ecou-iesc `rar_env`
|
||||||
|
- [ ] valoare invalida -> 422 fara echo de input (handler global pastrat)
|
||||||
|
- [ ] `python3 -m pytest tests/test_api_rar_target.py -q` PASS
|
||||||
|
- **Verificare E2E**: `POST /v1/prezentari` fara `rar_target` pe un cont prod-only -> submission env=prod.
|
||||||
|
|
||||||
|
### US-006: Worker — sesiuni si trimitere per (cont, env)
|
||||||
|
**Ca** worker **vreau** login/JWT separat per `(account_id, rar_env)`, cu base_url + creds corecte per submission
|
||||||
|
**pentru ca** test si prod sunt sisteme RAR diferite.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/worker/__main__.py` (`AccountSessions`), `app/rar_client.py` (base_url per env),
|
||||||
|
`app/reconcile.py`, `tests/test_worker_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_worker_rar_env.py` — `test_sesiune_separata_per_env`,
|
||||||
|
`test_base_url_dupa_submission`, `test_creds_din_slotul_env`, `test_reconcile_pe_env_corect`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] cheia cache sesiune = `(account_id, rar_env)`; JWT/keepalive/last_rar_login_ok per env
|
||||||
|
- [ ] `RarClient` primeste env/base_url explicit (nu doar `settings.rar_base_url`)
|
||||||
|
- [ ] creds alese: submission efemere -> `accounts.rar_creds_{env}_enc`; lipsa -> blocaj clar (nu trimite)
|
||||||
|
- [ ] reconcilierea cauta in `finalizate` pe endpoint-ul `submission.rar_env`
|
||||||
|
- [ ] purjarea atinge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_{env}_enc`
|
||||||
|
- [ ] `python3 -m pytest tests/test_worker_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: doua submission-uri (test + prod, creds prezente) -> doua login-uri distincte in jurnal.
|
||||||
|
|
||||||
|
### US-007: Validare login pe env-ul ales (signup / preview / test integrare)
|
||||||
|
**Ca** sistem **vreau** ca validarea credentialelor sa loveasca mediul caruia ii apartin **pentru ca** o parola
|
||||||
|
prod nu se valideaza contra RAR test si invers (confirmat: 401 incrucisat).
|
||||||
|
|
||||||
|
- **Depinde de**: US-002
|
||||||
|
- **Fisiere**: `app/web/routes.py`, `app/rar_client.py`, `app/web/templates/_integrare.html`,
|
||||||
|
`tests/test_validare_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_validare_env.py` — `test_valideaza_pe_env_creds`, `test_mesaj_distinge_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] validarea (signup, „testeaza integrarea", preview) foloseste env-ul setului de creds verificat
|
||||||
|
- [ ] mesaj distinct „creds invalide pe TESTARE" vs „pe PRODUCTIE"
|
||||||
|
- [ ] `python3 -m pytest tests/test_validare_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: in UI „testeaza integrarea" cu creds prod -> login pe endpoint prod.
|
||||||
|
|
||||||
|
### US-008: Configurare cont — doua medii (bifa activare + creds), default, confirmare prod
|
||||||
|
**Ca** titular de cont **vreau** sa activez fiecare mediu, sa-i introduc credentialele si sa aleg default-ul
|
||||||
|
**pentru ca** vreau sa controlez unde se poate trimite si unde merge implicit.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001, US-007
|
||||||
|
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_cont.html`, `app/crypto.py` (refolosit),
|
||||||
|
`tests/test_cont_medii.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_cont_medii.py` — `test_activeaza_si_salveaza_creds_per_env`,
|
||||||
|
`test_default_doar_dintre_disponibile`, `test_activare_prod_cere_confirmare`, `test_creds_criptate_fara_echo`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] doua sectiuni „Testare" si „Productie": fiecare cu bifa Activeaza + campuri email/parola; default client =
|
||||||
|
Productie bifat, Testare nebifat
|
||||||
|
- [ ] la salvare, creds-ul fiecarui mediu activat e validat prin login pe acel env (US-007); invalid -> nu se
|
||||||
|
marcheaza disponibil
|
||||||
|
- [ ] selectorul de default ofera DOAR mediile disponibile; nu poti seta default un mediu indisponibil
|
||||||
|
- [ ] activarea mediului Productie cere o confirmare unica „Inteleg ca trimiterile pe Productie sunt declaratii
|
||||||
|
reale (L.142)"
|
||||||
|
- [ ] creds criptate Fernet in `rar_creds_{env}_enc`, niciodata reflectate inapoi in pagina
|
||||||
|
- [ ] `python3 -m pytest tests/test_cont_medii.py -q` PASS
|
||||||
|
- **Verificare E2E**: activez Testare + creds valide si Productie + creds invalide -> doar Testare devine disponibil.
|
||||||
|
|
||||||
|
### US-009: Import web — selector mediu conditionat de disponibilitate
|
||||||
|
**Ca** operator **vreau** sa aleg mediul la import doar cand am ≥2 disponibile, pre-bifat pe default **pentru ca**
|
||||||
|
la un singur mediu alegerea e inutila.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002, US-004
|
||||||
|
- **Fisiere**: `app/import_router.py`, `app/import_parse.py`, `app/web/templates/_upload.html`,
|
||||||
|
`_preview_import.html`, `tests/test_import_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_import_rar_env.py` — `test_selector_ascuns_la_un_mediu`,
|
||||||
|
`test_selector_prezent_si_prebifat_la_doua`, `test_commit_seteaza_env_pe_submissions`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] selector Test/Prod apare DOAR daca `len(medii_disponibile) >= 2`; initial = `rar_env_efectiv`
|
||||||
|
- [ ] la 1 mediu: fara selector, toate randurile primesc acel mediu
|
||||||
|
- [ ] la commit, toate submission-urile lotului primesc `rar_env` ales
|
||||||
|
- [ ] `python3 -m pytest tests/test_import_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: cont prod-only -> import fara selector, submissions env=prod; cont cu ambele -> selector pre-bifat.
|
||||||
|
|
||||||
|
### US-010: Badge mediu in liste, preview, jurnal, audit + ecou API
|
||||||
|
**Ca** utilizator **vreau** sa vad pe fiecare trimitere mediul tinta **pentru ca** sa nu confund testul cu realul.
|
||||||
|
|
||||||
|
- **Depinde de**: US-001
|
||||||
|
- **Fisiere**: `app/web/templates/_submissions.html`, `_coada.html`, `_trimitere_detaliu.html`,
|
||||||
|
`_preview_rand.html`, `_jurnal.html`, `app/web/routes.py` (audit export), `app/api/v1/router.py` (GET),
|
||||||
|
`tests/test_badge_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_badge_rar_env.py` — `test_badge_in_lista`, `test_audit_contine_rar_env`,
|
||||||
|
`test_get_ecou_rar_env`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] badge vizibil (Test vs Productie, culori distincte) in lista, preview rand, detaliu, jurnal
|
||||||
|
- [ ] `rar_env` in audit export si in `GET /v1/prezentari(/{id})`
|
||||||
|
- [ ] `python3 -m pytest tests/test_badge_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: rand prod -> badge „Productie"; export audit contine coloana.
|
||||||
|
|
||||||
|
### US-011: Statusbar — indicator mediu + toggle conditionat
|
||||||
|
**Ca** operator **vreau** sa vad in statusbar mediul default si sa-l pot schimba cand am ≥2 medii **pentru ca**
|
||||||
|
sa stiu mereu unde trimit si sa comut rapid.
|
||||||
|
|
||||||
|
- **Depinde de**: US-002, US-008
|
||||||
|
- **Fisiere**: `app/web/templates/_status.html`, `base.html`, `app/web/routes.py` (ruta toggle account-scoped),
|
||||||
|
`tests/test_statusbar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_statusbar_env.py` — `test_afiseaza_env_default`,
|
||||||
|
`test_toggle_doar_la_doua_medii`, `test_toggle_schimba_default`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] statusbar afiseaza mediul default al contului logat (Test/Productie), distinct vizual
|
||||||
|
- [ ] toggle apare DOAR la `len(medii_disponibile) >= 2`; comutarea schimba `rar_env_default` (HTMX, fara reload)
|
||||||
|
- [ ] la 1 mediu: doar eticheta statica
|
||||||
|
- [ ] `python3 -m pytest tests/test_statusbar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: cont cu ambele -> click statusbar schimba default; cont prod-only -> eticheta fixa „Productie".
|
||||||
|
|
||||||
|
### US-012: Audit + e2e pe medii
|
||||||
|
**Ca** lead **vreau** evenimente de audit la activare mediu / schimbare default / blocaj tinta, plus teste e2e
|
||||||
|
**pentru ca** orice atingere a mediului Productie trebuie trasabila.
|
||||||
|
|
||||||
|
- **Depinde de**: US-005, US-006, US-009, US-011
|
||||||
|
- **Fisiere**: `app/audit.py`/`log_event`, `tests/test_e2e_rar_env.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_e2e_rar_env.py` — `test_lant_import_pana_la_queued`, `test_activare_prod_logata`,
|
||||||
|
`test_tinta_indisponibila_blocata_si_logata`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] audit la: activare/dezactivare mediu, schimbare `rar_env_default`, blocaj tinta indisponibila
|
||||||
|
- [ ] e2e (TestClient + SQLite temporar) acopera import->queued cu env corect, ambele cai
|
||||||
|
- [ ] `python3 -m pytest tests/test_e2e_rar_env.py -q` PASS
|
||||||
|
- **Verificare E2E**: jurnal arata „mediu Productie activat" + „default schimbat" cu cont + timestamp.
|
||||||
|
|
||||||
|
### US-013: Retragerea `accounts.rar_creds_enc` (toate citirile -> per-env, apoi DROP)
|
||||||
|
**Ca** sistem **vreau** ca toate cele ~40 de locuri care citesc `accounts.rar_creds_enc` sa treaca pe coloanele
|
||||||
|
per-mediu si apoi sa sterg coloana veche **pentru ca** modelul per-env sa fie sursa unica, fara schema dubla.
|
||||||
|
|
||||||
|
- **Depinde de**: US-005, US-006, US-008 (consumatorii principali deja pe per-env)
|
||||||
|
- **Fisiere**: `app/worker/__main__.py` (fallback + bucla keepalive „toate conturile cu creds"),
|
||||||
|
`app/web/routes.py` (indicatorii `are_creds`), `app/api/v1/integrare_router.py` (`are_creds_rar`),
|
||||||
|
`app/api/v1/router.py` (`POST /v1/conturi/rar-creds` devine env-aware), `app/accounts.py` (purge la stergere cont),
|
||||||
|
`app/db.py` (DROP cu garda), `app/models.py`, `tests/test_retragere_creds_enc.py`
|
||||||
|
- **Test intai (RED)**: `tests/test_retragere_creds_enc.py` — `test_niciun_read_pe_coloana_veche`,
|
||||||
|
`test_conturi_rar_creds_env_aware`, `test_are_creds_pe_per_env`, `test_drop_cu_garda_blocat_daca_lipsa_copiere`
|
||||||
|
- **Acceptance criteria**:
|
||||||
|
- [ ] worker fallback + keepalive citesc `rar_creds_{env}_enc` (per env), nu coloana veche
|
||||||
|
- [ ] `are_creds` (web) + `are_creds_rar` (integrare) devin per-mediu („are creds pe Testare/Productie")
|
||||||
|
- [ ] `POST /v1/conturi/rar-creds` primeste mediul (`rar_target`/`env`) si scrie in slotul corect — **schimbare
|
||||||
|
de contract API**, documentata in `docs/api-rar-contract.md`
|
||||||
|
- [ ] purjarea la stergere cont (`accounts.py`) sterge ambele sloturi per-env
|
||||||
|
- [ ] **DROP cu garda**: migrarea verifica intai ca fiecare `rar_creds_enc` non-null a aterizat intr-un slot
|
||||||
|
per-env (assert), apoi `ALTER TABLE accounts DROP COLUMN rar_creds_enc` (SQLite 3.45 OK); verificare esuata
|
||||||
|
-> NU dropa, ridica eroare (fail-safe)
|
||||||
|
- [ ] **(AUTO-FIX 6a — CRITIC) Elimina ATOMIC blocul `ADD COLUMN rar_creds_enc` din `db.py:77-78`** in aceeasi
|
||||||
|
migrare cu DROP-ul. Altfel urmatorul boot vede coloana absenta si o re-ADD goala -> ping-pong perpetuu,
|
||||||
|
garda se rupe. Garda e one-way: dropeaza doar cand sloturile per-env sunt populate SI coloana inca exista
|
||||||
|
- [ ] **(AUTO-FIX 6b — HIGH) DROP-ul nu crapa boot-ul**: `init_db/_migrate` ruleaza la fiecare pornire a ambelor
|
||||||
|
procese; un `DROP COLUMN` care arunca (SQLite < 3.35 / assert garda esuat) propaga -> API + worker
|
||||||
|
crash-loop. Prinde + degradeaza (log + lasa coloana pe loc), NU arunca. Asserteaza `sqlite_version() >= 3.35`
|
||||||
|
(verifica SQLite din imaginea Docker, nu doar dev box) si sare drop-ul gracios sub acel prag
|
||||||
|
- [ ] **(AUTO-FIX 6c — HIGH) Re-ruleaza backfill old->new IMEDIAT inainte de assert**: creds setate via
|
||||||
|
`POST /v1/conturi/rar-creds` intre deploy-ul US-001 si US-013 aterizeaza doar in coloana veche; copiaza-le
|
||||||
|
in slotul per-env (ancora globala) inainte de garda, altfel garda blocheaza drop-ul la nesfarsit
|
||||||
|
- [ ] **(AUTO-FIX 6d) Verificare prin `PRAGMA table_info(accounts)`** ca `rar_creds_enc` lipseste, NU doar prin
|
||||||
|
grep (ambele coloane — `accounts` si `submissions` — au acelasi nume; purjarea worker-ului ramane pe
|
||||||
|
`submissions.rar_creds_enc`)
|
||||||
|
- [ ] `grep -rn "rar_creds_enc" app/` nu mai gaseste citiri pe `accounts` (doar `submissions.rar_creds_enc` ramane)
|
||||||
|
- [ ] `python3 -m pytest tests/test_retragere_creds_enc.py -q` PASS
|
||||||
|
- **Verificare E2E**: dupa migrare, `PRAGMA table_info(accounts)` nu mai contine `rar_creds_enc`; fluxul de cont
|
||||||
|
(salvare creds, worker trimite) functioneaza pe per-env.
|
||||||
|
|
||||||
|
## 5. Riscuri
|
||||||
|
|
||||||
|
- **Trimitere reala accidentala** (FINALIZATA terminal, L.142): atenuat prin badge omniprezent + Productie disponibil
|
||||||
|
doar dupa activare explicita + creds + confirmare unica la activare. NU exista anulare la RAR.
|
||||||
|
- **Default invalid dupa dezactivare mediu**: REQ-DEFAULT recalculeaza; teste US-002 acopera caderea pe disponibil.
|
||||||
|
- **Migrare ambigua** (CONFIRMAT): `rar_creds_enc` poate fi test SAU prod; migrarea aterizeaza in slotul
|
||||||
|
`AUTOPASS_RAR_ENV` global + activeaza doar acel mediu. De validat pe DB-ul real inainte de deploy.
|
||||||
|
- **Client prod-only nu poate testa**: corect by design; UI explica explicit (nu „creds invalide"), nu ofera Testare
|
||||||
|
fara creds test.
|
||||||
|
- **Idempotenta**: schimbarea cheii (US-003) cere ca TOATE apelurile sa treaca env-ul; grep dupa `build_key` + teste.
|
||||||
|
- **Retragere `rar_creds_enc` (US-013)**: ~40 citiri + endpoint API `POST /v1/conturi/rar-creds` (contract). Blast
|
||||||
|
radius mare, dar single-release e mai curat decat schema dubla. DROP cu garda (assert copiere) = fara pierdere
|
||||||
|
de date; produsul e in TESTE (putine conturi reale). Recuperarea via coloana veche dispare dupa DROP — acceptat.
|
||||||
|
|
||||||
|
## 6. Intrebari deschise — REZOLVATE (user 2026-06-29)
|
||||||
|
|
||||||
|
- [x] **Default API** = default-ul contului (NU „test" hardcodat), fiindca clientii sunt prod-only. CONFIRMAT.
|
||||||
|
- [x] **Activare implicita cont nou** = Productie on / Testare off; contul operator setat manual pe Testare. CONFIRMAT.
|
||||||
|
- [x] **Confirmare Productie** = o data, la activarea mediului in configurare (nu per trimitere). CONFIRMAT.
|
||||||
|
- [x] **`rar_creds_enc` vechi** = se STERGE in acest PRD (US-013), nu in 5.2x. DROP cu garda (assert copiere),
|
||||||
|
toate citirile mutate pe per-env, endpoint `POST /v1/conturi/rar-creds` devine env-aware. CONFIRMAT.
|
||||||
|
|
||||||
|
## 7. Valuri de executie (graful de dependente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Val 1: [US-001] [US-003] ← schema + idempotenta (fisiere distincte) → paralel
|
||||||
|
Val 2: [US-002] ← deblocat de US-001
|
||||||
|
Val 3: [US-004] [US-006] [US-007] ← rezolvare ingestie / worker / validare → paralel
|
||||||
|
Val 4: [US-005] [US-008] [US-009] [US-010] ← API / config cont / import / badge → paralel
|
||||||
|
Val 5: [US-011] ← statusbar (depinde de US-008)
|
||||||
|
Val 6: [US-012] [US-013] ← audit + e2e; retragere rar_creds_enc + DROP (depind de tot)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raport VERIFY
|
||||||
|
|
||||||
|
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||||
|
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- AUTONOMOUS DECISION LOG -->
|
||||||
|
## /autoplan Review (2026-06-29, commit 7371c37)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (independent). Codex indisponibil (usage limit, revine 18 iul) -> mod `[subagent-only]`. Poarta premisa: user a ales **"Build full per-account multi-env (as planned)"** — premisa de baza (sisteme separate) verificata live; nevoia de dashboard unic justifica per-cont peste 2 deployment-uri pinned.
|
||||||
|
|
||||||
|
### Auto-fixuri (corectitudine/siguranta — incorporate in stories)
|
||||||
|
|
||||||
|
| # | Story | Gap (gasit de) | Fix incorporat | Principiu |
|
||||||
|
|---|-------|----------------|----------------|-----------|
|
||||||
|
| G | US-001 | **CRITIC** (subagent): migrarea backfill-eaza creds dar NU `submissions.rar_env` existent; randuri prod pre-migrare cad pe DEFAULT 'test' -> US-006 reconciliaza contra endpoint TEST -> no-match -> **re-send prod = duplicat real ireversibil** | Migrarea backfill-eaza `submissions.rar_env` din `AUTOPASS_RAR_ENV` global (DEFAULT 'test' doar pentru randuri net-noi). Test: rand prod pre-migrare reconciliaza contra endpoint prod | P1 completeness + siguranta |
|
||||||
|
| L | **US-005/US-013** (NU US-006 — eng finding 5: write-back e in `router.py`, pe care US-006 nu-l atinge) | HIGH (ambele voci, `router.py:250`): write-back creds efemere API -> `accounts.rar_creds_enc` durabil nu e rutat pe slotul `submission.rar_env` | Write-back tinteste `accounts.rar_creds_{submission.rar_env}_enc` + test. **Plus**: nu auto-propaga creds API NEVALIDATE in slotul durabil per-env (ar putea clobber-i un slot login-validat); propaga doar dupa login reusit | P1 |
|
||||||
|
| K | US-013 | HIGH (subagent): `POST /v1/conturi/rar-creds` e contract extern; env-aware in-place = breaking | Endpoint **aditiv**: param `env` optional, default = default cont; apelanti vechi neatinsi. (Independent de decizia DROP) | P5 explicit + back-compat |
|
||||||
|
| M2 | US-013 | MEDIUM (Claude): `_keepalive_target` alege un cont fara notiune de env dupa per-env | Keepalive foloseste ancora globala `AUTOPASS_RAR_ENV` + un cont cu creds in slotul acelui env | P5 |
|
||||||
|
| M3 | US-003 | MEDIUM (Claude): `_already_sent_lookup` (import_router.py:369) are dual-lookup legacy; adaugarea env in cheie cere extinderea lui, nu doar a parametrului | US-003 extinde dual-lookup (cheie noua env-aware + fallback legacy) | P1 |
|
||||||
|
| D | US-001 | HIGH (subagent): corectitudinea migrarii e "de validat manual"; trebuie poarta testata | Script de audit pre-migrare (raporteaza slot-ul atribuit) + assert DROP-cu-garda existent ca poarta, nu nota manuala | P1 |
|
||||||
|
| M | US-012 | MEDIUM (subagent): niciun test live dual-env; riscul dominant (rutare gresita env) e exact ce SQLite nu prinde | Test live opt-in dual-env (extinde `test_live_rar`): 1 rand test + 1 prod -> 2 login-uri, 2 endpoint-uri, badge corecte, reconciliere pe env corect | P1 |
|
||||||
|
| backup | US-013 | MEDIUM (Claude): "recovery via coloana veche dispare dupa DROP — acceptat" | Inainte de DROP, dump coloana veche criptata intr-un backup timestamped (recuperare supravietuieste DROP) | P2 boil-lake |
|
||||||
|
|
||||||
|
### Decizii user la poarta finala (REZOLVATE 2026-06-29) — APROBAT
|
||||||
|
|
||||||
|
- **A (DROP US-013) -> PASTREAZA single-release.** User: "aplicatia e doar in teste, nu folosita de clienti" -> blast radius mic, rollback-ul conteaza mai putin. Decizia §6 ramane. **Garzile 6a/6b/6c sunt obligatorii in AC US-013** (eliminare atomica bloc ADD, catch+degrade fara boot-crash, re-backfill interim) + backup criptat inainte de DROP. NU se amana.
|
||||||
|
- **J/H1 (interlock prod) -> doar butonul de commit colorat (F8), FARA modal.** REQ-CONF ramane. Lantul: bifa activare (o data) + badge "fierbinte" + buton "Declară la PRODUCȚIE (real)". Fara confirmare per-commit (evita oboseala de click; clientii prod-only oricum n-au selector).
|
||||||
|
- **H (fallback default) -> doar toast zgomotos (F5), FARA re-confirmare.** REQ-DEFAULT auto-fallback ramane; toast-ul "Mediul implicit a trecut pe X" face flip-ul vizibil. Fara gate suplimentar.
|
||||||
|
|
||||||
|
### Taste (recomandari acceptate — fara override)
|
||||||
|
- **T1**: token dedicat `--prod` (brick) pentru badge-ul Productie. **T2**: `rar_env` ca nume unic input+output (scoate `rar_target`/`env`).
|
||||||
|
|
||||||
|
### Taste decisions (auto-decise cu recomandare — override la poarta)
|
||||||
|
- **T1 — token culoare Productie**: rosu (`--err`) se ciocneste cu erorile, amber (`--warn`) cu badge-ul legacy. Recomandat: token dedicat `--prod` (brick inchis) SAU `--accent` plin. (design F2)
|
||||||
|
- **T2 — nume camp request**: recomandat `rar_env` peste tot (un singur nume input+output), scoate `rar_target`/`env`. (DX F1)
|
||||||
|
|
||||||
|
### Teme cross-fază (semnal de incredere ridicat — aparut independent in 2+ faze)
|
||||||
|
- **Siguranta declaratiei reale ireversibile** — TOATE 4 fazele (CEO G/H1/J, Design F1/F8/F10, Eng 1b/3/G, DX F2/F3/F4). Semnalul dominant: badge + interlock + discoverability + rutare env corecta converg pe "nu declara real din greseala".
|
||||||
|
- **Flip silentios al mediului default** — CEO-H, Design-F5, DX-F3 (3 faze). Fa flip-ul zgomotos + nu auto-promova prod silentios.
|
||||||
|
- **Risc DROP US-013** — CEO-A, Eng 6a/6b/6c (2 faze). Intareste amanarea DROP-ului.
|
||||||
|
- **Ambiguitate spec/nume care musca implementer-ul** — Design-F14, Eng-4a, DX-F1/F7. Auto-fixurile TREBUIE sa intre in AC + contract inainte de implementare.
|
||||||
|
|
||||||
|
### NOT in scope (confirmat)
|
||||||
|
Eliminarea ancorei globale `AUTOPASS_RAR_ENV`; base_url din UI; al treilea mediu; gating plan/tier pe prod; schimbari masina-stari/backoff/payload; auto-migrare creds prod client. (PRD §2)
|
||||||
|
|
||||||
|
### Ce exista deja (leverage)
|
||||||
|
`crypto.py` Fernet (creds per-env), `AccountSessions` (re-key (cont,env)), `RarClient` (primeste settings; +param env), `config.rar_base_url_test/prod` (deja prezent), `build_key` (+param), `account_scope_clause`. Fara infra noua.
|
||||||
|
|
||||||
|
### Auto-fixuri DESIGN (structurale — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent. Scorecard: 1 CRITIC, 7 HIGH, 5 MEDIUM, toate CONFIRMED.
|
||||||
|
|
||||||
|
| # | Story | Gap | Fix incorporat | Sev |
|
||||||
|
|---|-------|-----|----------------|-----|
|
||||||
|
| F1 | US-010 | **CRITIC**: "culori distincte" e singura spec a singurului guard vizual contra riscului dominant | Badge **normativ**: Productie = fill plin, saturat, text alb, iconita + cuvant complet UPPERCASE cu diacritice ("PRODUCȚIE"); Testare = outline/tint linistit (muted/accent), receding. Asimetria de greutate ESTE designul | CRITIC |
|
||||||
|
| F2 | US-010 | HIGH: rosu (`--err`) rezervat erorilor, amber (`--warn`) ocupat de `.badge-env` legacy + needs_* | Token dedicat `--prod` (ex. brick `#B4452F`) SAU `--accent` plin pentru Productie; hex/token scris in AC, nu improvizat per template. (taste: hexul exact -> poarta) | HIGH |
|
||||||
|
| F3/F12 | US-010 | HIGH: "Test/Testare/prod/PRODUCTIE" folosite interschimbabil; bypass `labels.py` | `labels.py` adaugat in Fisiere: `ETICHETE_ENV` + `eticheta_env(env)->(text,css)` (oglindeste `eticheta_scurta`). Productie UPPERCASE+diacritice, Testare title-case; clase `.badge-prod/.badge-test` definite o data in base.html langa `.sugg-sursa` | HIGH |
|
||||||
|
| F11 | US-011 | HIGH: `.badge-env` EXISTENT in header arata `AUTOPASS_RAR_ENV` global -> dupa 5.20 e semantic gresit; doua indicatoare env cu surse diferite in acelasi viewport | US-011 retrage/repurpune header `.badge-env` (preferat: scos pentru user logat, inlocuit de indicatorul account-scoped din statusbar). NU coexista doua surse de adevar | HIGH |
|
||||||
|
| F4 | US-009 | HIGH: starea 0-medii e numita dar nedesignata; blocaj la commit (dupa munca) = calea minima | Blocaj la UPLOAD (nu commit): banner `--warn` (refoloseste pattern "Cont in asteptare", `_status.html:8`) + CTA link `?tab=cont`, inainte de drop-zone | HIGH |
|
||||||
|
| F5 | US-011 | HIGH: schimbarea silentioasa a default-ului (mediu dezactivat) nu are UI -> target real/test comuta fara ca userul sa stie | Toast explicit (componenta `#toast` exista) la schimbarea `rar_env_default` ca efect al disponibilitatii: "Mediul implicit a trecut pe X". Leaga de CEO-H | HIGH |
|
||||||
|
| F8 | US-009 | HIGH: o bifa la activare apoi nimic = sub-avertizare; modalul per-trimitere a fost respins (REQ-CONF) | Butonul de commit POARTA greutatea cand target=Productie: "Declară la PRODUCȚIE (real)" + culoarea Productie (FARA modal, FARA click extra -> nu incalca REQ-CONF). Copy bifa activare: adauga ireversibilitatea ("declarații oficiale, finale și fără anulare") | HIGH |
|
||||||
|
| F6/F7 | US-008/US-011 | MEDIUM: stari loading/error pt toggle HTMX + validare creds la RAR nespecificate; stare per-sectiune (activat-fara-creds-valide) | toggle: `hx-indicator` + disabled in zbor, pe esec NU schimba default + eroare; US-008 validare creds arata `htmx-indicator` ("se verifica la RAR…") + esec in `.banner` cu copy per-env (US-007); fiecare sectiune arata 3 stari: dezactivat / activat-fara-creds / disponibil | MEDIUM |
|
||||||
|
| F9/F10 | US-009 | MEDIUM/HIGH: selectorul absent la 1 mediu = env invizibil la import; default pre-bifat prod la prima trimitere | Mereu randeaza un indicator env la import (eticheta statica la 1 mediu, toggle la >=2, ACEEASI pozitie). Prod pre-bifat e sigur DOAR daca F8+F9 livreaza impreuna — legate explicit in AC | HIGH |
|
||||||
|
| F13 | US-010 | MEDIUM: sa nu forkeze un badge structural nou | Refoloseste idiomul `.sugg-sursa` (10px, weight 700, tint+border) pt Testare; Productie = aceeasi geometrie dar fill plin+alb+icon (spargerea e semnalul) | MEDIUM |
|
||||||
|
|
||||||
|
### Auto-fixuri ENG (corectitudine/deploy — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (verificat contra codului real). **Meta (eng 4a): toate auto-fixurile de mai jos sunt NORMATIVE si trebuie sa intre in AC-ul story-urilor inainte de implementare — un implementer care urmeaza AC-ul literal, fara ele, livreaza bug-urile critice.** G + 6a deja imbinate in AC US-001/US-013.
|
||||||
|
|
||||||
|
| # | Story | Gap (vs cod real) | Fix | Sev |
|
||||||
|
|---|-------|-------------------|-----|-----|
|
||||||
|
| E1/1a | US-006 | `get_token` purjeaza `submissions.rar_creds_enc WHERE account_id=?` -> dupa re-key, login TEST sterge creds efemere ale submission-urilor PROD ale contului -> prod blocat | `WHERE account_id=? AND rar_env=?` + test `test_purge_creds_doar_pe_env` | HIGH |
|
||||||
|
| 1b/E6 | US-006 | `recover_orphans` filtreaza doar pe `account_id`; iterat per sesiune (cont,env) reconciliaza orfanii prod contra endpoint TEST -> no-match -> re-POST prod = DUPLICAT real | +`rar_env` in WHERE; apelat per (cont,env) din `active()`; test orfan env A nereconciliat contra env B | HIGH/CRITIC |
|
||||||
|
| 3/E4 | US-003 | API channel (`router.py:223`) NU are dual-lookup; re-POST al unui rand pre-5.20 cu cheie env-aware rateaza randul legacy -> duplicat. Import dual-lookup ignora env-ul randului matchuit | Recompute-keys la migrare (US-001, vezi acolo) acopera ambele canale uniform; daca pastrezi dual-lookup, exista si in `router.py` SI gate pe `matched_row.rar_env==target_env` | HIGH |
|
||||||
|
| 1c/E8 | US-006 | `claim_one` nu selecteaza `s.rar_env` -> worker nu poate alege cheia sesiune/base_url/slot | AC explicit: claim selecteaza + propaga `rar_env` in dict-ul `claimed` | MEDIUM |
|
||||||
|
| 1d | US-006/US-001 | `worker_heartbeat` e un singur rand global (`WHERE id=1`); US-006 cere `last_rar_login_ok` PER env dar US-001 nu adauga schema per-env -> neimplementabil ca scris | Decizie: pastreaza heartbeat global (JWT/sesiune per env e suficient), scoate "per env" din AC US-006; SAU adauga coloana in US-001. Recomandat: global | MEDIUM |
|
||||||
|
| 1e | US-006 (doc) | `_refresh_nomenclator` upsert intr-un `nomenclator_rar` env-less la fiecare login; login test suprascrie cu coduri test, prod cu prod -> un cod valid pe prod poate fi respins la ingestie daca ultimul refresh a fost test | Documenteaza presupunerea (nomenclator identic intre medii — aceleasi 18 coduri) SAU scope per-env (out of scope acum). Minim: nota explicita | MEDIUM |
|
||||||
|
| 5 | US-005/US-013 | write-back creds API nevalidate -> slot durabil (vezi L de mai sus) | re-asignat la US-005/US-013; propaga doar dupa login reusit | MEDIUM/HIGH |
|
||||||
|
| 6a..6d | US-013 | ping-pong re-ADD / boot-crash / interim-creds / grep ambiguu | imbinate in AC US-013 (vezi acolo) | CRITIC/HIGH |
|
||||||
|
|
||||||
|
### ENG DUAL VOICES — CONSENSUS TABLE
|
||||||
|
```
|
||||||
|
Dimension Claude Subagent Consensus
|
||||||
|
────────────────────────────── ──────── ───────── ────────────────────
|
||||||
|
1. Architecture sound? da/cond da/cond CONFIRMED (cond. fixuri)
|
||||||
|
2. Test coverage sufficient? lacune +API b/c CONFIRMED lacune
|
||||||
|
3. Performance risks? low low CONFIRMED low
|
||||||
|
4. Security (creds routing)? L/5 5+unvalid CONFIRMED
|
||||||
|
5. Error paths (boot)? E1/E9 6a/6b CRIT CONFIRMED (boot-crash)
|
||||||
|
6. Deployment risk (DROP)? migrare CRIT/HIGH CONFIRMED ELEVAT -> intareste challenge A
|
||||||
|
```
|
||||||
|
Codex: indisponibil (N/A). Mesaj-cheie: caile de duplicat ireversibil (1b, 3) si boot-crash/ping-pong (6a, 6b) musca in productie; intaresc recomandarea de a amana DROP-ul (challenge A).
|
||||||
|
|
||||||
|
### Diagrama teste (codepath -> acoperire)
|
||||||
|
| Codepath nou | Story test | Stare |
|
||||||
|
|---|---|---|
|
||||||
|
| `medii_disponibile`/`rar_env_efectiv` | US-002 | acoperit |
|
||||||
|
| resolve target (cerere>default), respinge indisponibil | US-004 | acoperit |
|
||||||
|
| idempotency env-aware + **recompute legacy** | US-003/US-001 | GAP recompute -> adaugat |
|
||||||
|
| **migrare backfill `submissions.rar_env`** | US-001 | GAP (G) -> adaugat in AC |
|
||||||
|
| worker sesiune (cont,env) + base_url per env | US-006 | acoperit |
|
||||||
|
| **purge creds scoped pe env** | US-006 | GAP (E1) -> adaugat |
|
||||||
|
| **recover_orphans per env** | US-006 | GAP (1b) -> adaugat |
|
||||||
|
| **write-back slot routing** | US-005/013 | GAP (L/5) -> adaugat |
|
||||||
|
| reconcile endpoint per env (inline + **orfani**) | US-006 | inline acoperit; orfani GAP -> adaugat |
|
||||||
|
| **keepalive env (ancora globala)** | US-013 | GAP (M2) -> adaugat |
|
||||||
|
| DROP garda: assert + **idempotent re-run** + **fail-loud/no-crash** | US-013 | partial -> intarit (6a/6b/6c) |
|
||||||
|
| **API-channel idempotency back-compat** | US-003 | GAP (3) -> adaugat |
|
||||||
|
| badge/labels env | US-010 | acoperit |
|
||||||
|
| API `rar_target` default/explicit/invalid/indisponibil | US-005 | acoperit |
|
||||||
|
| config 2 sectiuni + confirmare prod | US-008 | acoperit |
|
||||||
|
| statusbar toggle viz + **retragere header `.badge-env`** | US-011 | toggle acoperit; header GAP (F11) -> adaugat |
|
||||||
|
| **live dual-env smoke** | US-012 | GAP (M) -> adaugat opt-in |
|
||||||
|
|
||||||
|
### Auto-fixuri DX (contract API extern — incorporate in stories)
|
||||||
|
|
||||||
|
Voci: Claude (primar) + Claude subagent (perspectiva integrator VFP/ROAAUTO). Riscul ireversibilitatii ridica stacheta pe claritate nume / eroare / discoverability pre-trimitere.
|
||||||
|
|
||||||
|
| # | Story | Gap | Fix | Sev |
|
||||||
|
|---|-------|-----|-----|-----|
|
||||||
|
| F1 | US-005/US-013 | Trei nume pt un concept: input `rar_target`, echo/DB `rar_env`, rar-creds `env` (US-013 AC scrie literal "rar_target/env") | **Un singur cheie: `rar_env`** pe input + output + rar-creds (englez snake, consistent cu coloana si `on_unmapped_error`). Scoate `rar_target`/`env`. (taste usor -> poarta) | HIGH |
|
||||||
|
| F2 | US-004 | Eroarea "mediu indisponibil" e proza, fara `cod`/envelope 6-chei/status; `errors.py` nu e in Fisiere | `RAR_MEDIU_INDISPONIBIL` in `errors.CATALOG` (problema/cauza cu lista disponibile/fix "activeaza in Cont"); adauga `errors.py` la Fisiere US-004; distinge literal-invalid (422 pydantic) de valid-dar-indisponibil (cod dedicat); acopera si cazul 0-medii | HIGH |
|
||||||
|
| F3 | US-004/contract | Flip runtime test->prod prin canal web: operator comuta disponibilitatea -> apelant API fara `rar_env` trece silentios pe prod (real). Migrarea previne flip la DEPLOY, nu la RUNTIME | Mitigat de F4+F5 (probe pre-trimitere); documenteaza reasignarea ca comportament cunoscut; leaga de CEO-H | HIGH |
|
||||||
|
| F4 | US-010 (sau story noua) | Niciun GET nu expune `medii_disponibile`/`rar_env_default` -> integratorul afla env-ul doar din eroare sau dupa o trimitere reala | `GET /v1/conturi/medii` account-scoped: `{medii_disponibile, rar_env_default, test:{enabled,has_creds}, prod:{...}}` (refoloseste helper US-002, <1 fisier) | HIGH |
|
||||||
|
| F5 | US-005 | `ValidareResult` (dry-run) NU ecou-ieste `rar_env`; dry-run e canalul sigur de a confirma unde ar ateriza o trimitere reala | adauga `rar_env: str` la `ValidareResult` + `/valideaza`; `models.py` | MEDIUM |
|
||||||
|
| F6 | US-004/US-005 | Respingere whole-request vs per-rand inconsistenta cu `on_unmapped_error` (per-rand, 200) | Decide + documenteaza; recomandat: corp parsabil imbogatit cu `cod` (prietenos VFP), noteaza asimetria intentionat | MEDIUM |
|
||||||
|
| F7 | US-005/US-010/US-004/US-013 | Contractul (sursa adevar) actualizat doar pt rar-creds; lipsesc field-ul nou, echo-ul, cod-ul nou. **`/v1/conturi/rar-creds` NU e documentat deloc azi** -> US-013 e documentare de la zero, nu amendament | AC explicit "update `api-rar-contract.md`" pe fiecare; US-013 documenteaza endpoint-ul intreg (req/resp, param env, slot default) | HIGH |
|
||||||
|
| F8 | US-013 (doc) | `env` optional default = slot default cont: integrator cu creds TEST pe cont nou (default prod) le scrie silentios in slot prod -> US-007 le respinge "invalide pe PRODUCTIE" desi sunt valide (test) | pastreaza aditiv; documenteaza ca omiterea `env` tinteste slotul default; mesaj validare sugereaza nepotrivire env ("creds valide pentru alt mediu?") | MEDIUM |
|
||||||
|
|
||||||
|
### DX DUAL VOICES — CONSENSUS TABLE
|
||||||
|
```
|
||||||
|
Dimension Claude Subagent Consensus
|
||||||
|
─────────────────────────────── ─────── ───────── ──────────────
|
||||||
|
1. Getting started (aditiv)? low fr low fr CONFIRMED low
|
||||||
|
2. Naming guessable? D1 incon F1 3-nume CONFIRMED -> rar_env
|
||||||
|
3. Error messages actionable? D2 gap F2 gap CONFIRMED gap
|
||||||
|
4. Docs findable & complete? D4 gap F7 gap+ CONFIRMED gap
|
||||||
|
5. Back-compat safe? D3 resid F3 runtime CONFIRMED (1 rezidual)
|
||||||
|
6. Discoverability pre-send? D5 gap F4 gap CONFIRMED gap
|
||||||
|
```
|
||||||
|
Codex: indisponibil (N/A). DX scor initial: ~6/10 (model API solid + aditiv, dar nume inconsistent + eroare neimbogatita + zero discoverability + contract neactualizat). Tinta dupa fixuri: ~9/10.
|
||||||
|
|
||||||
|
### Jurnal integrator (condensat)
|
||||||
|
| Etapa | Azi (plan brut) | Dupa fixuri DX |
|
||||||
|
|---|---|---|
|
||||||
|
| Afla env-urile contului | doar din eroare / dupa trimitere reala | `GET /v1/conturi/medii` |
|
||||||
|
| Trimite | `rar_target` (nume #1) | `rar_env` (un nume) |
|
||||||
|
| Confirma tinta fara trimitere reala | imposibil (valideaza nu ecou-ieste) | `/valideaza` ecou-ieste `rar_env` |
|
||||||
|
| Eroare tinta indisponibila | proza, fara cod | `cod: RAR_MEDIU_INDISPONIBIL` + fix |
|
||||||
|
| Citeste rezultatul | `rar_env` (nume #2) | `rar_env` (acelasi) |
|
||||||
|
| Doc | contract fara field/endpoint | contract complet |
|
||||||
@@ -112,7 +112,10 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
|||||||
assert ids == sorted(ids)
|
assert ids == sorted(ids)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
assert "rar_creds_enc" not in r
|
assert "rar_creds_enc" not in r
|
||||||
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at", "tier", "trial_until"}
|
assert set(r.keys()) == {
|
||||||
|
"id", "name", "cui", "email", "active", "status", "created_at",
|
||||||
|
"tier", "trial_until", "requested_plan", "consent_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
226
tests/test_api_rar_target.py
Normal file
226
tests/test_api_rar_target.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""Teste US-005 — camp rar_env pe POST /v1/prezentari si /valideaza.
|
||||||
|
|
||||||
|
Acopera: default din cont, tinta explicita, respingere tinta indisponibila,
|
||||||
|
echo GET, valoare invalida (422 Pydantic), echo dry-run valideaza.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
"""DB temporara izolata per test + settings reincarcate."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
yield monkeypatch
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _body(rar_env=None, **over):
|
||||||
|
prez = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123",
|
||||||
|
"nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15",
|
||||||
|
"odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
prez.update(over)
|
||||||
|
body = {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
||||||
|
if rar_env is not None:
|
||||||
|
body["rar_env"] = rar_env
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_prod_only(conn):
|
||||||
|
"""Configureaza contul 1 ca prod-only (rar_prod_enabled=1, creds prod, default prod)."""
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
enc = encrypt_creds({"email": "prod@rar.ro", "password": "paraprod"})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=?, "
|
||||||
|
"rar_test_enabled=0, rar_creds_test_enc=NULL, rar_env_default='prod' WHERE id=1",
|
||||||
|
(enc,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_dual_env(conn):
|
||||||
|
"""Configureaza contul 1 cu ambele medii disponibile, default test."""
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
enc_test = encrypt_creds({"email": "test@rar.ro", "password": "paratest"})
|
||||||
|
enc_prod = encrypt_creds({"email": "prod@rar.ro", "password": "paraprod"})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=?, "
|
||||||
|
"rar_test_enabled=1, rar_creds_test_enc=?, rar_env_default='test' WHERE id=1",
|
||||||
|
(enc_prod, enc_test),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_default_din_cont_cand_lipseste #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_default_din_cont_cand_lipseste(env):
|
||||||
|
"""Cont prod-only, POST fara rar_env -> submission rar_env='prod'."""
|
||||||
|
with _client() as c:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_setup_prod_only(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
r = c.post("/v1/prezentari", json=_body())
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status"] == "queued"
|
||||||
|
assert res["rar_env"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_target_explicit #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_target_explicit(env):
|
||||||
|
"""Cont cu ambele medii, POST cu rar_env='test' -> submission rar_env='test'."""
|
||||||
|
with _client() as c:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_setup_dual_env(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
r = c.post("/v1/prezentari", json=_body(rar_env="test"))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["status"] == "queued"
|
||||||
|
assert res["rar_env"] == "test"
|
||||||
|
|
||||||
|
# Aceeasi prezentare cu rar_env='prod' -> cheie diferita (env-aware) -> alt submission
|
||||||
|
r2 = c.post("/v1/prezentari", json=_body(rar_env="prod"))
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
res2 = r2.json()["results"][0]
|
||||||
|
assert res2["rar_env"] == "prod"
|
||||||
|
# Nu e dedup (env diferit -> cheie diferita)
|
||||||
|
assert res2["submission_id"] != res["submission_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_target_indisponibil_respins #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_target_indisponibil_respins(env):
|
||||||
|
"""Cont prod-only, POST cu rar_env='test' -> 422 RAR_MEDIU_INDISPONIBIL, fara enqueue."""
|
||||||
|
with _client() as c:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_setup_prod_only(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
r = c.post("/v1/prezentari", json=_body(rar_env="test"))
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail["cod"] == "RAR_MEDIU_INDISPONIBIL"
|
||||||
|
# Cauza contine mediul cerut si lista disponibilelor
|
||||||
|
assert "test" in detail["cauza"]
|
||||||
|
assert "prod" in detail["cauza"]
|
||||||
|
|
||||||
|
# Verifica ca nu s-a facut enqueue
|
||||||
|
lista = c.get("/v1/prezentari").json()["submissions"]
|
||||||
|
assert lista == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_get_ecou_rar_env #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_get_ecou_rar_env(env):
|
||||||
|
"""Dupa enqueue, GET /v1/prezentari/{id} si lista contin rar_env."""
|
||||||
|
with _client() as c:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_setup_prod_only(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Enqueue pe prod (default contului prod-only)
|
||||||
|
r = c.post("/v1/prezentari", json=_body())
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
sub_id = r.json()["results"][0]["submission_id"]
|
||||||
|
assert sub_id is not None
|
||||||
|
|
||||||
|
# GET detaliu
|
||||||
|
r_det = c.get(f"/v1/prezentari/{sub_id}")
|
||||||
|
assert r_det.status_code == 200, r_det.text
|
||||||
|
assert r_det.json()["rar_env"] == "prod"
|
||||||
|
|
||||||
|
# GET lista
|
||||||
|
r_lst = c.get("/v1/prezentari")
|
||||||
|
assert r_lst.status_code == 200, r_lst.text
|
||||||
|
sub_in_lista = next(s for s in r_lst.json()["submissions"] if s["id"] == sub_id)
|
||||||
|
assert sub_in_lista["rar_env"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_valoare_invalida_422 #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_valoare_invalida_422(env):
|
||||||
|
"""POST cu rar_env='staging' -> 422 din Pydantic Literal, fara echo de input."""
|
||||||
|
with _client() as c:
|
||||||
|
body = _body(rar_env="staging")
|
||||||
|
r = c.post("/v1/prezentari", json=body)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
# Handler-ul global sterge 'input'/'ctx' — valoarea invalida nu se ecou-ieste.
|
||||||
|
assert "staging" not in r.text
|
||||||
|
# Fara enqueue
|
||||||
|
lista = c.get("/v1/prezentari").json()["submissions"]
|
||||||
|
assert lista == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# test_valideaza_ecou_rar_env #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_valideaza_ecou_rar_env(env):
|
||||||
|
"""POST /valideaza (dry-run) ecou-ieste rar_env rezolvat in ValidareResult."""
|
||||||
|
with _client() as c:
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
_setup_prod_only(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Dry-run fara rar_env -> default cont = prod
|
||||||
|
r = c.post("/v1/prezentari/valideaza", json=_body())
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
res = r.json()["results"][0]
|
||||||
|
assert res["rar_env"] == "prod"
|
||||||
|
|
||||||
|
# Dry-run cu rar_env='prod' explicit
|
||||||
|
r2 = c.post("/v1/prezentari/valideaza", json=_body(rar_env="prod"))
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["results"][0]["rar_env"] == "prod"
|
||||||
|
|
||||||
|
# Dry-run tinta indisponibila -> 422, fara echo sensibil
|
||||||
|
r3 = c.post("/v1/prezentari/valideaza", json=_body(rar_env="test"))
|
||||||
|
assert r3.status_code == 422, r3.text
|
||||||
|
assert r3.json()["detail"]["cod"] == "RAR_MEDIU_INDISPONIBIL"
|
||||||
313
tests/test_cont_medii.py
Normal file
313
tests/test_cont_medii.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""Teste US-008 (PRD 5.20): configurare medii RAR per cont — Testare + Productie.
|
||||||
|
|
||||||
|
Ruta testata: POST /cont/rar-medii
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
test_activeaza_si_salveaza_creds_per_env -- creds salvate criptat, mediu marcat disponibil
|
||||||
|
test_default_doar_dintre_disponibile -- mediu implicit validat contra disponibilelor
|
||||||
|
test_activare_prod_cere_confirmare -- prima activare prod cere checkbox L.142
|
||||||
|
test_creds_criptate_fara_echo -- parola niciodata in clar in DB sau HTML
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
"""Client izolat cu DB temporara + cheie Fernet pentru criptare creds."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_medii.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
|
||||||
|
from app.config import get_settings
|
||||||
|
from app import crypto
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
crypto.reset_cache()
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
with TestClient(app, follow_redirects=False) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
crypto.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpere
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _create_account_user(
|
||||||
|
name: str = "Service Test SRL",
|
||||||
|
email: str = "user@test.com",
|
||||||
|
password: str = "parolasecreta10",
|
||||||
|
):
|
||||||
|
"""Creeaza cont + user. Returneaza (acct_id, user_id)."""
|
||||||
|
from app.accounts import create_account
|
||||||
|
from app.users import create_user
|
||||||
|
from app.db import get_connection
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
acct_id = create_account(conn, name, active=True)
|
||||||
|
user_id = create_user(conn, acct_id, email, password)
|
||||||
|
return acct_id, user_id
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, email: str, password: str) -> None:
|
||||||
|
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||||
|
resp = client.get("/login")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||||
|
assert m, "csrf_token negasit pe /login"
|
||||||
|
csrf = m.group(1)
|
||||||
|
|
||||||
|
resp = client.post("/login", data={
|
||||||
|
"email": email,
|
||||||
|
"parola": password,
|
||||||
|
"csrf_token": csrf,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_csrf(client) -> str:
|
||||||
|
"""Obtine CSRF token din fragmentul /_fragments/cont."""
|
||||||
|
resp = client.get("/_fragments/cont")
|
||||||
|
assert resp.status_code == 200, f"/_fragments/cont a returnat {resp.status_code}"
|
||||||
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||||
|
assert m, f"csrf_token negasit in /_fragments/cont: {resp.text[:400]}"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_login_ok(monkeypatch) -> None:
|
||||||
|
"""Monkeypatch _valideaza_login_rar sa returneze (True, None) fara RAR live."""
|
||||||
|
import app.web.routes as routes_mod
|
||||||
|
monkeypatch.setattr(routes_mod, "_valideaza_login_rar", lambda *a, **kw: (True, None))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teste
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_activeaza_si_salveaza_creds_per_env(client, monkeypatch):
|
||||||
|
"""Activez Testare cu creds valide (mock) -> DB: rar_test_enabled=1, rar_creds_test_enc non-null.
|
||||||
|
medii_disponibile si test_disponibil reflecta starea noua.
|
||||||
|
"""
|
||||||
|
_mock_login_ok(monkeypatch)
|
||||||
|
acct_id, _ = _create_account_user("Firma T1", "t1@test.com")
|
||||||
|
_login(client, "t1@test.com", "parolasecreta10")
|
||||||
|
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"test_enabled": "1",
|
||||||
|
"test_email": "rar_test@firma.ro",
|
||||||
|
"test_parola": "parolaRARtest",
|
||||||
|
# prod_enabled absent -> rar_prod_enabled setat la 0
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT rar_test_enabled, rar_creds_test_enc, rar_prod_enabled FROM accounts WHERE id=?",
|
||||||
|
(acct_id,),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row["rar_test_enabled"] == 1, "rar_test_enabled trebuia setat la 1"
|
||||||
|
assert row["rar_creds_test_enc"] is not None, "rar_creds_test_enc trebuia salvat"
|
||||||
|
# Indicator test_disponibil sau mesaj succes in HTML
|
||||||
|
assert "configurat" in resp.text or "salvate si validate" in resp.text, \
|
||||||
|
f"Indicator 'configurat' sau mesaj succes lipsa: {resp.text[:600]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_doar_dintre_disponibile(client, monkeypatch):
|
||||||
|
"""Incerc sa setez rar_env_default pe un mediu indisponibil -> valoarea veche ramane + eroare.
|
||||||
|
Setarea pe mediu disponibil reuseste.
|
||||||
|
"""
|
||||||
|
_mock_login_ok(monkeypatch)
|
||||||
|
acct_id, _ = _create_account_user("Firma T2", "t2@test.com")
|
||||||
|
_login(client, "t2@test.com", "parolasecreta10")
|
||||||
|
|
||||||
|
# Pasul 1: activeaza Testare cu creds + seteaza default=test (test va fi singurul disponibil)
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp1 = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"test_enabled": "1",
|
||||||
|
"test_email": "rar_test@firma.ro",
|
||||||
|
"test_parola": "parolaRAR123",
|
||||||
|
"rar_env_default": "test",
|
||||||
|
# prod_enabled absent -> rar_prod_enabled=0 (prod indisponibil)
|
||||||
|
})
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
assert "actualizat" in resp1.text.lower(), \
|
||||||
|
f"Mesaj 'actualizat' asteptat pentru setarea default=test: {resp1.text[:500]}"
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row1 = conn.execute(
|
||||||
|
"SELECT rar_env_default FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row1["rar_env_default"] == "test", "rar_env_default trebuia setat la 'test'"
|
||||||
|
|
||||||
|
# Pasul 2: incerc sa setez default=prod (prod indisponibil: enabled=0, fara creds)
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp2 = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"test_enabled": "1", # re-trimit enabled fara creds noi (creds existente raman)
|
||||||
|
"rar_env_default": "prod",
|
||||||
|
})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert "disponibil" in resp2.text.lower(), \
|
||||||
|
f"Eroare 'nu e disponibil' asteptata in raspuns: {resp2.text[:500]}"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row2 = conn.execute(
|
||||||
|
"SELECT rar_env_default FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row2["rar_env_default"] == "test", \
|
||||||
|
"rar_env_default NU trebuia schimbat la 'prod' (mediu indisponibil)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_activare_prod_cere_confirmare(client, monkeypatch):
|
||||||
|
"""Prima activare Productie (de la dezactivat) fara prod_confirmare -> NU se activeaza.
|
||||||
|
Cu prod_confirmare=1 -> rar_prod_enabled devine 1.
|
||||||
|
"""
|
||||||
|
_mock_login_ok(monkeypatch)
|
||||||
|
acct_id, _ = _create_account_user("Firma T3", "t3@test.com")
|
||||||
|
_login(client, "t3@test.com", "parolasecreta10")
|
||||||
|
|
||||||
|
# Pasul 0: dezactiveaza prod (schema default=1, trebuie adus la 0 pt a testa confirmarea)
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
# prod_enabled absent -> rar_prod_enabled setat la 0
|
||||||
|
# test_enabled absent -> rar_test_enabled setat la 0
|
||||||
|
})
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row0 = conn.execute(
|
||||||
|
"SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row0["rar_prod_enabled"] == 0, "Prod trebuia dezactivat in pasul 0"
|
||||||
|
|
||||||
|
# Pasul 1: incerc sa activez prod FARA confirmare -> refuzat
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp1 = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"prod_enabled": "1",
|
||||||
|
"prod_email": "rar_prod@firma.ro",
|
||||||
|
"prod_parola": "parolaRARprod",
|
||||||
|
# prod_confirmare absent
|
||||||
|
})
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
text1 = resp1.text.lower()
|
||||||
|
assert "confirmare" in text1 or "l.142" in text1 or "inteleg" in text1, \
|
||||||
|
f"Mesaj de confirmare asteptat in raspuns: {resp1.text[:600]}"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row1 = conn.execute(
|
||||||
|
"SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row1["rar_prod_enabled"] == 0, "rar_prod_enabled NU trebuia activat fara confirmare"
|
||||||
|
|
||||||
|
# Pasul 2: activeaza cu confirmare -> reuseste
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp2 = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"prod_enabled": "1",
|
||||||
|
"prod_email": "rar_prod@firma.ro",
|
||||||
|
"prod_parola": "parolaRARprod",
|
||||||
|
"prod_confirmare": "1",
|
||||||
|
})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row2 = conn.execute(
|
||||||
|
"SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row2["rar_prod_enabled"] == 1, "rar_prod_enabled trebuia activat cu confirmare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creds_criptate_fara_echo(client, monkeypatch):
|
||||||
|
"""Dupa salvare, rar_creds_test_enc e criptat (nu parola in clar) si
|
||||||
|
parola NU apare in HTML-ul raspuns.
|
||||||
|
"""
|
||||||
|
_mock_login_ok(monkeypatch)
|
||||||
|
acct_id, _ = _create_account_user("Firma T4", "t4@test.com")
|
||||||
|
_login(client, "t4@test.com", "parolasecreta10")
|
||||||
|
|
||||||
|
parola_test = "SECRETPAROLATEST999"
|
||||||
|
csrf = _get_csrf(client)
|
||||||
|
resp = client.post("/cont/rar-medii", data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"test_enabled": "1",
|
||||||
|
"test_email": "rar_test@firma.ro",
|
||||||
|
"test_parola": parola_test,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Parola NU trebuie sa apara in HTML-ul raspuns
|
||||||
|
assert parola_test not in resp.text, \
|
||||||
|
f"Parola apare in raspunsul HTML (echo interzis): {resp.text[:500]}"
|
||||||
|
|
||||||
|
# In DB: rar_creds_test_enc e criptat (nu contine parola in clar)
|
||||||
|
from app.db import get_connection
|
||||||
|
from app.crypto import decrypt_creds
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT rar_creds_test_enc FROM accounts WHERE id=?", (acct_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
enc = row["rar_creds_test_enc"]
|
||||||
|
assert enc is not None, "rar_creds_test_enc trebuia salvat"
|
||||||
|
assert parola_test not in enc, "Parola in clar gasita in rar_creds_test_enc (neacceptat)"
|
||||||
|
|
||||||
|
# Decriptarea trebuie sa recupereze parola originala
|
||||||
|
creds = decrypt_creds(enc)
|
||||||
|
assert creds is not None, "Decriptarea a returnat None"
|
||||||
|
assert creds.get("password") == parola_test, \
|
||||||
|
f"Parola decriptata nu corespunde: {creds!r}"
|
||||||
@@ -157,7 +157,7 @@ class FakeRarClient:
|
|||||||
|
|
||||||
made: list = []
|
made: list = []
|
||||||
|
|
||||||
def __init__(self, settings=None, login_exc=None):
|
def __init__(self, settings=None, *, base_url=None, login_exc=None):
|
||||||
self.closed = False
|
self.closed = False
|
||||||
self.login_calls = 0
|
self.login_calls = 0
|
||||||
self._login_exc = login_exc
|
self._login_exc = login_exc
|
||||||
@@ -234,7 +234,7 @@ def test_get_token_bad_creds_raises(env, monkeypatch):
|
|||||||
import app.worker.__main__ as w
|
import app.worker.__main__ as w
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
|
|
||||||
def _factory(settings=None):
|
def _factory(settings=None, **kwargs):
|
||||||
return FakeRarClient(settings, login_exc=RarAuthError("Credentiale RAR invalide", status_code=401))
|
return FakeRarClient(settings, login_exc=RarAuthError("Credentiale RAR invalide", status_code=401))
|
||||||
|
|
||||||
monkeypatch.setattr(w, "RarClient", _factory)
|
monkeypatch.setattr(w, "RarClient", _factory)
|
||||||
|
|||||||
28
tests/test_idempotency.py
Normal file
28
tests/test_idempotency.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""US-003 (PRD 5.20): build_key incorporeaza rar_env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.idempotency import build_key, canonicalize_row
|
||||||
|
|
||||||
|
|
||||||
|
def _canon():
|
||||||
|
raw = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B 123 ABC",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456.0",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
canon = canonicalize_row(raw)
|
||||||
|
canon["prestatii"] = raw["prestatii"]
|
||||||
|
return canon
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_difera_intre_test_si_prod():
|
||||||
|
canon = _canon()
|
||||||
|
assert build_key(1, canon, "test") != build_key(1, canon, "prod")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_stabil_pe_env():
|
||||||
|
canon = _canon()
|
||||||
|
assert build_key(1, canon, "prod") == build_key(1, canon, "prod")
|
||||||
|
# None si 1 colapseaza la aceeasi cheie (account_or_default), pe acelasi env
|
||||||
|
assert build_key(None, canon, "test") == build_key(1, canon, "test")
|
||||||
@@ -511,7 +511,7 @@ class TestE2EMixedQueue:
|
|||||||
|
|
||||||
# 4. Worker cu MockRar injectat prin AccountSessions (simulam bucla worker)
|
# 4. Worker cu MockRar injectat prin AccountSessions (simulam bucla worker)
|
||||||
mock_rar = MockRar(id_prezentare=66001, login_token="tok-mock")
|
mock_rar = MockRar(id_prezentare=66001, login_token="tok-mock")
|
||||||
monkeypatch.setattr(w, "RarClient", lambda settings=None: mock_rar)
|
monkeypatch.setattr(w, "RarClient", lambda settings=None, **kw: mock_rar)
|
||||||
|
|
||||||
sessions = w.AccountSessions(settings)
|
sessions = w.AccountSessions(settings)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -595,7 +595,7 @@ class TestE2EMixedQueue:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
mock_rar = MockRar(id_prezentare=77777)
|
mock_rar = MockRar(id_prezentare=77777)
|
||||||
monkeypatch.setattr(w, "RarClient", lambda settings=None: mock_rar)
|
monkeypatch.setattr(w, "RarClient", lambda settings=None, **kw: mock_rar)
|
||||||
|
|
||||||
sessions = w.AccountSessions(settings)
|
sessions = w.AccountSessions(settings)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
359
tests/test_import_rar_env.py
Normal file
359
tests/test_import_rar_env.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""Teste US-009 (PRD 5.20) — Import web: selector mediu RAR conditionat de disponibilitate.
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
- La 0 medii: banner avertisment non-blocant (upload functioneaza, commit foloseste ancora globala).
|
||||||
|
- La 1 mediu: eticheta statica, fara selector; submissions primesc acel mediu.
|
||||||
|
- La 2 medii: selector vizibil pre-bifat pe default-ul contului.
|
||||||
|
- La commit: toate submission-urile lotului primesc rar_env ales (sau fallback ancora globala).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixture client cu DB izolat #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rar_env_test.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||||
|
monkeypatch.setenv("AUTOPASS_RAR_ENV", "test") # ancora globala = test
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.crypto import reset_cache
|
||||||
|
reset_cache()
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
get_settings.cache_clear()
|
||||||
|
reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Utilitare #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_nomenclator_si_mapare(client: TestClient) -> None:
|
||||||
|
"""Semeaza nomenclatorul si o mapare pentru randuri ok."""
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
|
||||||
|
("R-FRANE", "Reparatie frane"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO operations_mapping "
|
||||||
|
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
|
||||||
|
("OP-FRANE", "R-FRANE"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _configureaza_un_mediu(client: TestClient, env: str = "test") -> None:
|
||||||
|
"""Activeaza un singur mediu RAR pe contul 1 (simulate creds disponibile)."""
|
||||||
|
from app.db import get_connection
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
fake_creds = encrypt_creds({"email": "test@rar.ro", "password": "pass"})
|
||||||
|
if env == "test":
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=?, "
|
||||||
|
"rar_prod_enabled=0, rar_creds_prod_enc=NULL, rar_env_default='test' WHERE id=1",
|
||||||
|
(fake_creds,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=?, "
|
||||||
|
"rar_test_enabled=0, rar_creds_test_enc=NULL, rar_env_default='prod' WHERE id=1",
|
||||||
|
(fake_creds,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _configureaza_doua_medii(client: TestClient, default_env: str = "test") -> None:
|
||||||
|
"""Activeaza ambele medii RAR pe contul 1."""
|
||||||
|
from app.db import get_connection
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
fake_test = encrypt_creds({"email": "test@rar.ro", "password": "pass_test"})
|
||||||
|
fake_prod = encrypt_creds({"email": "prod@rar.ro", "password": "pass_prod"})
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET "
|
||||||
|
"rar_test_enabled=1, rar_creds_test_enc=?, "
|
||||||
|
"rar_prod_enabled=1, rar_creds_prod_enc=?, "
|
||||||
|
"rar_env_default=? WHERE id=1",
|
||||||
|
(fake_test, fake_prod, default_env),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_ROWS_OK = [
|
||||||
|
{
|
||||||
|
"VIN": "WVWZZZ1KZAW009001",
|
||||||
|
"Nr": "B009TST",
|
||||||
|
"Data": "2026-06-15",
|
||||||
|
"KM": "77000",
|
||||||
|
"Operatie": "OP-FRANE",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_si_mapare(client: TestClient, rows: list[dict]) -> int:
|
||||||
|
"""Upload CSV si seteaza mapare coloane. Intoarce import_id."""
|
||||||
|
data = _csv_bytes(rows)
|
||||||
|
r = client.post(
|
||||||
|
"/_import/upload",
|
||||||
|
files={"file": ("test.csv", io.BytesIO(data), "text/csv")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
m = re.search(r"/_import/(\d+)/", r.text)
|
||||||
|
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
|
||||||
|
iid = int(m.group(1))
|
||||||
|
|
||||||
|
# Seteaza maparea daca nu e deja
|
||||||
|
if f"/_import/{iid}/mapare-coloane" in r.text or "mapare-coloane" in r.text.lower():
|
||||||
|
r2 = client.post(
|
||||||
|
f"/_import/{iid}/mapare-coloane",
|
||||||
|
data={
|
||||||
|
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
|
||||||
|
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||||
|
"format_data": "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
|
||||||
|
return iid
|
||||||
|
|
||||||
|
|
||||||
|
def _get_preview(client: TestClient, iid: int) -> str:
|
||||||
|
rp = client.get(f"/_import/{iid}/preview")
|
||||||
|
assert rp.status_code == 200, rp.text
|
||||||
|
return rp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(client: TestClient, iid: int, n_ok: int, rar_env: str | None = None) -> object:
|
||||||
|
data = {
|
||||||
|
"csrf_token": "",
|
||||||
|
"n_confirmat": str(n_ok),
|
||||||
|
"confirmed_by": "test@us009.ro",
|
||||||
|
}
|
||||||
|
if rar_env:
|
||||||
|
data["rar_env"] = rar_env
|
||||||
|
return client.post(f"/_import/{iid}/confirma", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tests #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_selector_ascuns_la_un_mediu(client):
|
||||||
|
"""La 1 mediu disponibil: nu apare selector; apare eticheta statica cu mediul."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_un_mediu(client, env="test")
|
||||||
|
|
||||||
|
# GET fragment/import: verifica ca nu exista selector si apare eticheta
|
||||||
|
r = client.get("/_fragments/import")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# Eticheta statica "Testare" trebuie sa fie prezenta
|
||||||
|
assert "Testare" in html, "Eticheta mediu 'Testare' lipseste la 1 mediu disponibil"
|
||||||
|
|
||||||
|
# Selectorul nu trebuie sa apara (input cu name=rar_env hidden, dar fara <select>)
|
||||||
|
assert "<select" not in html or 'name="rar_env"' not in html or "rar-env-select" not in html, (
|
||||||
|
"Selector mediu RAR nu trebuie sa apara la 1 mediu disponibil"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selector_prezent_si_prebifat_la_doua(client):
|
||||||
|
"""La 2 medii disponibile: selectorul apare si e pre-bifat pe default-ul contului."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_doua_medii(client, default_env="test")
|
||||||
|
|
||||||
|
r = client.get("/_fragments/import")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# Selectorul trebuie sa apara
|
||||||
|
assert "rar-env-select" in html, "Selectorul mediu RAR lipseste la 2 medii disponibile"
|
||||||
|
assert 'name="rar_env"' in html, 'Atribut name="rar_env" lipsa din selector'
|
||||||
|
|
||||||
|
# Default pre-selectat = "test" (default contului)
|
||||||
|
# Optiunea Testare trebuie sa fie selectata
|
||||||
|
assert 'value="test"' in html and "selected" in html, (
|
||||||
|
"Optiunea Testare nu e pre-selectata (default cont = test)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_avertisment_la_zero_medii(client):
|
||||||
|
"""La 0 medii configurate: apare un banner de avertisment (non-blocant)."""
|
||||||
|
# Contul 1 implicit nu are medii configurate
|
||||||
|
r = client.get("/_fragments/import")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# Banner avertisment sau link catre configurare credentiale
|
||||||
|
assert "mediu" in html.lower() or "configureaza" in html.lower() or "credentiale" in html.lower(), (
|
||||||
|
"Bannerul de avertisment pentru 0 medii lipseste din pagina de upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload-ul NU e blocat: formularul de upload trebuie sa fie prezent
|
||||||
|
assert "upload-form" in html, (
|
||||||
|
"Formularul de upload lipseste — la 0 medii upload-ul nu trebuie blocat"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_seteaza_env_pe_submissions(client):
|
||||||
|
"""La commit: submissions primesc rar_env ales (fallback la ancora globala pt 0 medii)."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
# Contul 1 fara medii configurate -> ancora globala = "test"
|
||||||
|
|
||||||
|
iid = _upload_si_mapare(client, _ROWS_OK)
|
||||||
|
preview_html = _get_preview(client, iid)
|
||||||
|
|
||||||
|
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', preview_html)
|
||||||
|
n_ok = int(m_ok.group(1)) if m_ok else 1
|
||||||
|
|
||||||
|
r = _commit(client, iid, n_ok)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert any(kw in r.text.lower() for kw in ("coada", "prezenta", "trimiter")), (
|
||||||
|
"Mesajul de succes lipseste din raspunsul de commit"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verifica ca submission-ul are rar_env setat (fallback "test" via ancora globala)
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
sub = conn.execute(
|
||||||
|
"SELECT rar_env FROM submissions WHERE account_id=1 ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert sub is not None, "Niciun submission gasit dupa commit"
|
||||||
|
assert sub["rar_env"] in ("test", "prod"), f"rar_env invalid: {sub['rar_env']!r}"
|
||||||
|
# Cu AUTOPASS_RAR_ENV=test si 0 medii configurate, expect "test"
|
||||||
|
assert sub["rar_env"] == "test", (
|
||||||
|
f"Expected rar_env='test' (ancora globala) dar primit {sub['rar_env']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_cu_un_mediu_seteaza_acel_mediu(client):
|
||||||
|
"""La commit cu 1 mediu configurat: submission primeste mediul respectiv."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_un_mediu(client, env="test")
|
||||||
|
|
||||||
|
iid = _upload_si_mapare(client, _ROWS_OK)
|
||||||
|
preview_html = _get_preview(client, iid)
|
||||||
|
|
||||||
|
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', preview_html)
|
||||||
|
n_ok = int(m_ok.group(1)) if m_ok else 1
|
||||||
|
|
||||||
|
r = _commit(client, iid, n_ok)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
sub = conn.execute(
|
||||||
|
"SELECT rar_env FROM submissions WHERE account_id=1 ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert sub is not None
|
||||||
|
assert sub["rar_env"] == "test", (
|
||||||
|
f"Expected rar_env='test' (singurul mediu disponibil) dar primit {sub['rar_env']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_cu_doua_medii_respecta_alegerea(client):
|
||||||
|
"""La 2 medii: commit cu rar_env explicit seteaza mediul ales pe submissions."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_doua_medii(client, default_env="test")
|
||||||
|
|
||||||
|
iid = _upload_si_mapare(client, _ROWS_OK)
|
||||||
|
preview_html = _get_preview(client, iid)
|
||||||
|
|
||||||
|
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', preview_html)
|
||||||
|
n_ok = int(m_ok.group(1)) if m_ok else 1
|
||||||
|
|
||||||
|
# Commit explicit pe "prod"
|
||||||
|
r = _commit(client, iid, n_ok, rar_env="prod")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
from app.db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
sub = conn.execute(
|
||||||
|
"SELECT rar_env FROM submissions WHERE account_id=1 ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert sub is not None
|
||||||
|
assert sub["rar_env"] == "prod", (
|
||||||
|
f"Expected rar_env='prod' (ales explicit) dar primit {sub['rar_env']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_mediu_in_preview(client):
|
||||||
|
"""Preview-ul afiseaza badge-ul cu mediul tinta (US-009, F9/F10)."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_un_mediu(client, env="test")
|
||||||
|
|
||||||
|
iid = _upload_si_mapare(client, _ROWS_OK)
|
||||||
|
|
||||||
|
r = client.get(f"/_import/{iid}/preview")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# Badge cu mediul trebuie sa fie prezent in HTML
|
||||||
|
assert "Testare" in html or "PRODUCTIE" in html or "rar_env" in html, (
|
||||||
|
"Badge-ul de mediu RAR lipseste din preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rar_env_in_confirm_form(client):
|
||||||
|
"""Preview-ul contine un field hidden rar_env in formularul de confirmare."""
|
||||||
|
_seed_nomenclator_si_mapare(client)
|
||||||
|
_configureaza_un_mediu(client, env="test")
|
||||||
|
|
||||||
|
iid = _upload_si_mapare(client, _ROWS_OK)
|
||||||
|
|
||||||
|
r = client.get(f"/_import/{iid}/preview")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# Formularul de confirmare trebuie sa contina rar_env ca hidden field
|
||||||
|
assert 'name="rar_env"' in html, (
|
||||||
|
"Campul hidden 'rar_env' lipseste din formularul de confirmare preview"
|
||||||
|
)
|
||||||
50
tests/test_rar_env_disponibil.py
Normal file
50
tests/test_rar_env_disponibil.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""US-002 (PRD 5.20): medii_disponibile + rar_env_efectiv (REQ-DISP / REQ-DEFAULT)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.rar_env import medii_disponibile, rar_env_efectiv
|
||||||
|
|
||||||
|
|
||||||
|
def _cont(**kw):
|
||||||
|
base = {
|
||||||
|
"rar_test_enabled": 0, "rar_prod_enabled": 0,
|
||||||
|
"rar_creds_test_enc": None, "rar_creds_prod_enc": None,
|
||||||
|
"rar_env_default": "prod",
|
||||||
|
}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_doar_prod_cu_creds():
|
||||||
|
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc="TOK")
|
||||||
|
assert medii_disponibile(c) == ["prod"]
|
||||||
|
assert rar_env_efectiv(c) == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambele():
|
||||||
|
c = _cont(
|
||||||
|
rar_test_enabled=1, rar_creds_test_enc="T",
|
||||||
|
rar_prod_enabled=1, rar_creds_prod_enc="P",
|
||||||
|
rar_env_default="test",
|
||||||
|
)
|
||||||
|
assert medii_disponibile(c) == ["test", "prod"]
|
||||||
|
assert rar_env_efectiv(c) == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_cand_lipsesc_creds():
|
||||||
|
# activat dar fara creds -> nu e disponibil
|
||||||
|
c = _cont(rar_test_enabled=1, rar_prod_enabled=1)
|
||||||
|
assert medii_disponibile(c) == []
|
||||||
|
assert rar_env_efectiv(c) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_cade_pe_singurul_disponibil():
|
||||||
|
# default='prod' dar prod nu e disponibil; doar test e -> efectiv = test
|
||||||
|
c = _cont(rar_test_enabled=1, rar_creds_test_enc="T", rar_env_default="prod")
|
||||||
|
assert medii_disponibile(c) == ["test"]
|
||||||
|
assert rar_env_efectiv(c) == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_fara_creds_nu_e_disponibil():
|
||||||
|
c = _cont(rar_prod_enabled=1, rar_creds_prod_enc=" ") # whitespace = gol
|
||||||
|
assert medii_disponibile(c) == []
|
||||||
96
tests/test_rar_env_resolve.py
Normal file
96
tests/test_rar_env_resolve.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Teste US-004 (PRD 5.20): rezolvare mediu tinta la ingestie + respingere tinte indisponibile.
|
||||||
|
|
||||||
|
Fixtura `conn` urmareste acelasi pattern ca tests/test_accounts.py:
|
||||||
|
monkeypatch AUTOPASS_DB_PATH pe tempdir, cache_clear, init_db, get_connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_rar_env_resolve.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _seteaza_cont_ambele(conn) -> None:
|
||||||
|
"""Configureaza contul id=1 cu ambele medii disponibile, default = prod."""
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE accounts
|
||||||
|
SET rar_test_enabled=1, rar_creds_test_enc='T',
|
||||||
|
rar_prod_enabled=1, rar_creds_prod_enc='P',
|
||||||
|
rar_env_default='prod'
|
||||||
|
WHERE id=1"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seteaza_cont_doar_prod(conn) -> None:
|
||||||
|
"""Configureaza contul id=1 cu doar prod disponibil, test off."""
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE accounts
|
||||||
|
SET rar_test_enabled=0, rar_creds_test_enc=NULL,
|
||||||
|
rar_prod_enabled=1, rar_creds_prod_enc='P',
|
||||||
|
rar_env_default='prod'
|
||||||
|
WHERE id=1"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cerere_castiga(conn):
|
||||||
|
"""Cererea explicita bate default-ul contului (prod)."""
|
||||||
|
_seteaza_cont_ambele(conn)
|
||||||
|
from app.rar_env import rezolva_rar_env
|
||||||
|
rezultat = rezolva_rar_env(conn, 1, "test")
|
||||||
|
assert rezultat == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_default_cont(conn):
|
||||||
|
"""Fara cerere explicita -> default-ul contului (prod)."""
|
||||||
|
_seteaza_cont_ambele(conn)
|
||||||
|
from app.rar_env import rezolva_rar_env
|
||||||
|
rezultat = rezolva_rar_env(conn, 1, None)
|
||||||
|
assert rezultat == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tinta_indisponibila_respinsa(conn):
|
||||||
|
"""Cerere pentru 'test' pe un cont doar-prod -> MediuIndisponibil cu .disponibile=['prod']."""
|
||||||
|
_seteaza_cont_doar_prod(conn)
|
||||||
|
from app.rar_env import MediuIndisponibil, rezolva_rar_env
|
||||||
|
with pytest.raises(MediuIndisponibil) as exc_info:
|
||||||
|
rezolva_rar_env(conn, 1, "test")
|
||||||
|
err = exc_info.value
|
||||||
|
assert err.env == "test"
|
||||||
|
assert err.disponibile == ["prod"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_valoare_invalida(conn):
|
||||||
|
"""Cerere cu valoare in afara VALID_ENVS -> ValueError, fara fallback silentios."""
|
||||||
|
from app.rar_env import rezolva_rar_env
|
||||||
|
with pytest.raises(ValueError, match="mediu invalid"):
|
||||||
|
rezolva_rar_env(conn, 1, "staging")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_medii_cade_pe_ancora(conn, monkeypatch):
|
||||||
|
"""Cont fara niciun mediu disponibil -> ancora globala AUTOPASS_RAR_ENV."""
|
||||||
|
# id=1 din fresh DB: rar_prod_enabled=1 dar rar_creds_prod_enc=NULL -> 0 disponibile
|
||||||
|
# (valoarea implicita a schemei: prod enabled fara creds -> nedisponibil)
|
||||||
|
monkeypatch.setenv("AUTOPASS_RAR_ENV", "test")
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.rar_env import rezolva_rar_env
|
||||||
|
rezultat = rezolva_rar_env(conn, 1, None)
|
||||||
|
assert rezultat == "test"
|
||||||
145
tests/test_schema_migrate.py
Normal file
145
tests/test_schema_migrate.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""US-001 (PRD 5.20): schema medii per cont + env pe submission + migrare/backfill."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fresh_conn(monkeypatch):
|
||||||
|
"""DB nou cu schema curenta (init_db)."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
c = get_connection()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_db(path: str) -> sqlite3.Connection:
|
||||||
|
"""Construieste un DB in forma PRE-5.20 (fara coloanele de mediu)."""
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
|
||||||
|
"cui TEXT, rar_creds_enc TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
|
||||||
|
"payload_json TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_old(path: str, env: str, monkeypatch) -> sqlite3.Connection:
|
||||||
|
monkeypatch.setenv("AUTOPASS_RAR_ENV", env)
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
conn = sqlite3.connect(path, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_coloane_medii_pe_cont(fresh_conn):
|
||||||
|
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert {
|
||||||
|
"rar_test_enabled", "rar_prod_enabled",
|
||||||
|
"rar_creds_test_enc", "rar_creds_prod_enc", "rar_env_default",
|
||||||
|
} <= acc
|
||||||
|
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
|
||||||
|
assert "rar_env" in sub
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_client_prod_on_test_off(fresh_conn):
|
||||||
|
from app.accounts import create_account
|
||||||
|
aid = create_account(fresh_conn, "Service X")
|
||||||
|
row = fresh_conn.execute(
|
||||||
|
"SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?",
|
||||||
|
(aid,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["rar_prod_enabled"] == 1
|
||||||
|
assert row["rar_test_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("env,slot,other", [
|
||||||
|
("test", "rar_creds_test_enc", "rar_creds_prod_enc"),
|
||||||
|
("prod", "rar_creds_prod_enc", "rar_creds_test_enc"),
|
||||||
|
])
|
||||||
|
def test_migrare_creds_in_slotul_env_global(tmp_path, monkeypatch, env, slot, other):
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
old.execute(
|
||||||
|
"INSERT INTO accounts (id, name, rar_creds_enc) VALUES (5, 'Legacy', 'TOKEN_CREDS')"
|
||||||
|
)
|
||||||
|
old.close()
|
||||||
|
conn = _migrate_old(path, env, monkeypatch)
|
||||||
|
row = conn.execute("SELECT * FROM accounts WHERE id=5").fetchone()
|
||||||
|
assert row[slot] == "TOKEN_CREDS"
|
||||||
|
assert row[other] is None
|
||||||
|
assert row[f"rar_{env}_enabled"] == 1
|
||||||
|
assert row[f"rar_{'prod' if env == 'test' else 'test'}_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == env
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_cont_fara_creds_ramane_pe_default(tmp_path, monkeypatch):
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
old.execute("INSERT INTO accounts (id, name, rar_creds_enc) VALUES (6, 'NoCreds', NULL)")
|
||||||
|
old.close()
|
||||||
|
conn = _migrate_old(path, "test", monkeypatch)
|
||||||
|
row = conn.execute("SELECT * FROM accounts WHERE id=6").fetchone()
|
||||||
|
assert row["rar_prod_enabled"] == 1
|
||||||
|
assert row["rar_test_enabled"] == 0
|
||||||
|
assert row["rar_env_default"] == "prod"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_submissions_rar_env(tmp_path, monkeypatch):
|
||||||
|
"""Un rand PRE-migrare ajunge cu env-ul global (NU 'test') + cheie recalculata env-aware."""
|
||||||
|
path = str(tmp_path / "old.db")
|
||||||
|
old = _old_db(path)
|
||||||
|
payload = {
|
||||||
|
"vin": "WVWZZZ1JZXW000001", "nr_inmatriculare": "B123ABC",
|
||||||
|
"data_prestatie": "2026-01-10", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||||
|
}
|
||||||
|
old.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||||
|
"VALUES ('LEGACY_KEY', 7, 'sent', ?)",
|
||||||
|
(json.dumps(payload),),
|
||||||
|
)
|
||||||
|
old.close()
|
||||||
|
|
||||||
|
conn = _migrate_old(path, "prod", monkeypatch)
|
||||||
|
row = conn.execute("SELECT rar_env, idempotency_key FROM submissions").fetchone()
|
||||||
|
assert row["rar_env"] == "prod" # ancora globala, NU DEFAULT 'test'
|
||||||
|
|
||||||
|
from app.idempotency import build_key, canonicalize_row
|
||||||
|
canon = canonicalize_row(payload)
|
||||||
|
canon["prestatii"] = payload["prestatii"]
|
||||||
|
assert row["idempotency_key"] == build_key(7, canon, "prod")
|
||||||
|
# si difera de varianta env-aware pe test (reconciliere pe endpoint corect)
|
||||||
|
assert row["idempotency_key"] != build_key(7, canon, "test")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrare_idempotenta(fresh_conn):
|
||||||
|
"""A doua rulare _migrate pe DB deja migrat nu strica nimic."""
|
||||||
|
from app.db import _migrate
|
||||||
|
_migrate(fresh_conn) # nu arunca, nu dubleaza coloane
|
||||||
|
acc = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
||||||
|
assert "rar_env_default" in acc
|
||||||
@@ -39,7 +39,7 @@ def env(monkeypatch):
|
|||||||
class FakeRar:
|
class FakeRar:
|
||||||
"""Stub RarClient pentru teste."""
|
"""Stub RarClient pentru teste."""
|
||||||
|
|
||||||
def __init__(self, settings=None):
|
def __init__(self, settings=None, *, base_url=None):
|
||||||
self.login_calls = 0
|
self.login_calls = 0
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
|
|||||||
177
tests/test_validare_env.py
Normal file
177
tests/test_validare_env.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Teste US-007 (PRD 5.20): validare credentiale RAR pe env-ul setului de creds.
|
||||||
|
|
||||||
|
Premisa confirmata live (2026-06-29): creds prod NU se valideaza pe RAR test si
|
||||||
|
invers (401 incrucisat). Deci login-ul de proba TREBUIE sa loveasca endpoint-ul
|
||||||
|
mediului caruia ii apartin credentialele, nu URL-ul global AUTOPASS_RAR_ENV.
|
||||||
|
|
||||||
|
Functie testata:
|
||||||
|
routes._valideaza_login_rar(settings, email, password, env)
|
||||||
|
|
||||||
|
Teste:
|
||||||
|
test_valideaza_pe_env_creds -- login pe env='prod' foloseste base_url prod (nu test)
|
||||||
|
test_mesaj_distinge_env -- esec pe test vs prod produce mesaje diferite
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.rar_client import RarAuthError
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture izolat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env_db(monkeypatch):
|
||||||
|
"""DB temporara + settings curate. Numele 'env_db' evita coliziunea cu parametrul
|
||||||
|
'env' folosit in testele de mai jos ca string ('test'/'prod')."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_SEED_OPERATII_ENABLED", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stub-uri RarClient
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _RarClientSpy:
|
||||||
|
"""Inregistreaza base_url-ul cu care a fost construit si simuleaza login reusit."""
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def __init__(self, settings=None, *, base_url=None):
|
||||||
|
_RarClientSpy.captured["base_url"] = base_url
|
||||||
|
|
||||||
|
def login(self, email, password):
|
||||||
|
return "TOKEN-SPY"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _RarClientFail:
|
||||||
|
"""Simuleaza login esuat (RarAuthError 401) indiferent de env."""
|
||||||
|
|
||||||
|
def __init__(self, settings=None, *, base_url=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def login(self, email, password):
|
||||||
|
raise RarAuthError("Credentiale RAR invalide", status_code=401)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teste
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_valideaza_pe_env_creds(env_db, monkeypatch):
|
||||||
|
"""Cand validezi creds pentru env='prod', clientul de login e creat cu base_url-ul prod.
|
||||||
|
|
||||||
|
US-007 AC: 'validarea foloseste env-ul setului de creds verificat'.
|
||||||
|
Premisa: creds prod nu se valideaza pe RAR test (401 incrucisat), deci
|
||||||
|
clientul TREBUIE sa foloseasca base_url-ul prod, nu cel de test.
|
||||||
|
"""
|
||||||
|
import app.web.routes as routes_mod
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_RarClientSpy.captured = {}
|
||||||
|
monkeypatch.setattr(routes_mod, "RarClient", _RarClientSpy)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
ok, mesaj = routes_mod._valideaza_login_rar(settings, "a@b.ro", "parola", "prod")
|
||||||
|
|
||||||
|
assert ok is True, f"Login simulat trebuia sa reuseasca: mesaj={mesaj!r}"
|
||||||
|
assert mesaj is None
|
||||||
|
|
||||||
|
base_url_folosit = _RarClientSpy.captured.get("base_url")
|
||||||
|
assert base_url_folosit == settings.rar_base_url_prod, (
|
||||||
|
f"Clientul trebuia construit cu rar_base_url_prod={settings.rar_base_url_prod!r},"
|
||||||
|
f" dar a primit base_url={base_url_folosit!r}"
|
||||||
|
)
|
||||||
|
assert base_url_folosit != settings.rar_base_url_test, (
|
||||||
|
"Clientul nu trebuia sa foloseasca base_url-ul de TEST la validarea creds PROD"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valideaza_pe_env_creds_test(env_db, monkeypatch):
|
||||||
|
"""Cand validezi creds pentru env='test', clientul de login e creat cu base_url-ul test."""
|
||||||
|
import app.web.routes as routes_mod
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_RarClientSpy.captured = {}
|
||||||
|
monkeypatch.setattr(routes_mod, "RarClient", _RarClientSpy)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
ok, mesaj = routes_mod._valideaza_login_rar(settings, "a@b.ro", "parola", "test")
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
base_url_folosit = _RarClientSpy.captured.get("base_url")
|
||||||
|
assert base_url_folosit == settings.rar_base_url_test, (
|
||||||
|
f"Clientul trebuia construit cu rar_base_url_test={settings.rar_base_url_test!r},"
|
||||||
|
f" dar a primit base_url={base_url_folosit!r}"
|
||||||
|
)
|
||||||
|
assert base_url_folosit != settings.rar_base_url_prod
|
||||||
|
|
||||||
|
|
||||||
|
def test_mesaj_distinge_env(env_db, monkeypatch):
|
||||||
|
"""La esec de login pe test vs prod, mesajul difera ('TESTARE' vs 'PRODUCTIE').
|
||||||
|
|
||||||
|
US-007 AC: 'mesaj distinct creds invalide pe TESTARE vs pe PRODUCTIE'.
|
||||||
|
Design F6/F7: banner-ul de eroare indica pe ce mediu a esuat login-ul.
|
||||||
|
"""
|
||||||
|
import app.web.routes as routes_mod
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_mod, "RarClient", _RarClientFail)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
ok_test, msg_test = routes_mod._valideaza_login_rar(settings, "a@b.ro", "parola", "test")
|
||||||
|
ok_prod, msg_prod = routes_mod._valideaza_login_rar(settings, "a@b.ro", "parola", "prod")
|
||||||
|
|
||||||
|
assert ok_test is False, "Esecul la test trebuia sa returneze ok=False"
|
||||||
|
assert ok_prod is False, "Esecul la prod trebuia sa returneze ok=False"
|
||||||
|
|
||||||
|
assert msg_test is not None and "TESTARE" in msg_test, (
|
||||||
|
f"Mesajul la esec pe test trebuia sa contina 'TESTARE': {msg_test!r}"
|
||||||
|
)
|
||||||
|
assert msg_prod is not None and "PRODUCTIE" in msg_prod, (
|
||||||
|
f"Mesajul la esec pe prod trebuia sa contina 'PRODUCTIE': {msg_prod!r}"
|
||||||
|
)
|
||||||
|
# Cross-check: etichetele nu se amesteca
|
||||||
|
assert "PRODUCTIE" not in (msg_test or ""), (
|
||||||
|
f"Mesajul esec test nu trebuia sa mentioneze PRODUCTIE: {msg_test!r}"
|
||||||
|
)
|
||||||
|
assert "TESTARE" not in (msg_prod or ""), (
|
||||||
|
f"Mesajul esec prod nu trebuia sa mentioneze TESTARE: {msg_prod!r}"
|
||||||
|
)
|
||||||
@@ -224,8 +224,8 @@ def test_logo_linkeaza_acasa(client):
|
|||||||
"In prezent logo-ul nu e un link."
|
"In prezent logo-ul nu e un link."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Titlul "ROMFAST AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
|
# Titlul "ROA AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
|
||||||
# (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
|
# (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
|
||||||
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROMFAST AUTOPASS', header_html, re.DOTALL), (
|
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROA AUTOPASS', header_html, re.DOTALL), (
|
||||||
"Titlul 'ROMFAST AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
|
"Titlul 'ROA AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -741,20 +741,20 @@ def test_strip_sanatate_fara_hex_hardcodat():
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PRD 5.16 US-010: Titlu ROMFAST AUTOPASS + account_name in antet
|
# PRD 5.16 US-010: Titlu ROA AUTOPASS + account_name in antet
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def test_titlu_romfast_autopass(client):
|
def test_titlu_romfast_autopass(client):
|
||||||
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROMFAST AUTOPASS',
|
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROA AUTOPASS',
|
||||||
nu 'Gateway RAR AUTOPASS'."""
|
nu 'Gateway RAR AUTOPASS'."""
|
||||||
_create_account_user("titlutest@test.com", name="Service Titlu")
|
_create_account_user("titlutest@test.com", name="Service Titlu")
|
||||||
_login(client, "titlutest@test.com")
|
_login(client, "titlutest@test.com")
|
||||||
html = client.get("/?tab=acasa").text
|
html = client.get("/?tab=acasa").text
|
||||||
|
|
||||||
assert "ROMFAST AUTOPASS" in html, \
|
assert "ROA AUTOPASS" in html, \
|
||||||
"Titlul 'ROMFAST AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
|
"Titlul 'ROA AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
|
||||||
assert "Gateway RAR AUTOPASS" not in html, \
|
assert "Gateway RAR AUTOPASS" not in html, \
|
||||||
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROMFAST AUTOPASS'"
|
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROA AUTOPASS'"
|
||||||
|
|
||||||
|
|
||||||
def test_header_arata_nume_service_logat(client):
|
def test_header_arata_nume_service_logat(client):
|
||||||
@@ -772,7 +772,7 @@ def test_header_arata_nume_service_logat(client):
|
|||||||
|
|
||||||
def test_login_branded_nu_schelet(client):
|
def test_login_branded_nu_schelet(client):
|
||||||
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
|
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
|
||||||
titlul 'ROMFAST AUTOPASS', si formular cu POST /login (CSRF intact)."""
|
titlul 'ROA AUTOPASS', si formular cu POST /login (CSRF intact)."""
|
||||||
resp = client.get("/login")
|
resp = client.get("/login")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
@@ -781,8 +781,8 @@ def test_login_branded_nu_schelet(client):
|
|||||||
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
|
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
|
||||||
assert "login-aside" in html, \
|
assert "login-aside" in html, \
|
||||||
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
|
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
|
||||||
assert "ROMFAST AUTOPASS" in html, \
|
assert "ROA AUTOPASS" in html, \
|
||||||
"Titlul 'ROMFAST AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
|
"Titlul 'ROA AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
|
||||||
# Formular intact: POST /login cu csrf_token
|
# Formular intact: POST /login cu csrf_token
|
||||||
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
|
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
|
||||||
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
|
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ class _FakeSessions:
|
|||||||
self.invalidated: list[int] = []
|
self.invalidated: list[int] = []
|
||||||
self.tokens: list[int] = []
|
self.tokens: list[int] = []
|
||||||
|
|
||||||
def invalidate(self, account_id: int) -> None:
|
def invalidate(self, account_id: int, rar_env=None) -> None:
|
||||||
self.invalidated.append(account_id)
|
self.invalidated.append(account_id)
|
||||||
|
|
||||||
def get_token(self, conn, account_id: int, creds) -> str | None:
|
def get_token(self, conn, account_id: int, creds, rar_env="test") -> str | None:
|
||||||
self.tokens.append(account_id)
|
self.tokens.append(account_id)
|
||||||
if self._fail:
|
if self._fail:
|
||||||
raise RuntimeError("RAR jos")
|
raise RuntimeError("RAR jos")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class FakeRar:
|
|||||||
def test_login_reusit_logat(env, monkeypatch):
|
def test_login_reusit_logat(env, monkeypatch):
|
||||||
conn, settings = env
|
conn, settings = env
|
||||||
import app.worker.__main__ as w
|
import app.worker.__main__ as w
|
||||||
monkeypatch.setattr(w, "RarClient", lambda s: FakeRar())
|
monkeypatch.setattr(w, "RarClient", lambda s, **kw: FakeRar())
|
||||||
sessions = w.AccountSessions(settings)
|
sessions = w.AccountSessions(settings)
|
||||||
tok = sessions.get_token(conn, 2, {"email": "a@b.ro", "password": "secretaXY"})
|
tok = sessions.get_token(conn, 2, {"email": "a@b.ro", "password": "secretaXY"})
|
||||||
assert tok == "JWT-TEST"
|
assert tok == "JWT-TEST"
|
||||||
@@ -80,7 +80,7 @@ def test_login_reusit_logat(env, monkeypatch):
|
|||||||
def test_login_401_logat_fara_parola(env, monkeypatch):
|
def test_login_401_logat_fara_parola(env, monkeypatch):
|
||||||
conn, settings = env
|
conn, settings = env
|
||||||
import app.worker.__main__ as w
|
import app.worker.__main__ as w
|
||||||
monkeypatch.setattr(w, "RarClient", lambda s: FakeRar(login_exc=RarAuthError("401", status_code=401)))
|
monkeypatch.setattr(w, "RarClient", lambda s, **kw: FakeRar(login_exc=RarAuthError("401", status_code=401)))
|
||||||
sessions = w.AccountSessions(settings)
|
sessions = w.AccountSessions(settings)
|
||||||
with pytest.raises(RarAuthError):
|
with pytest.raises(RarAuthError):
|
||||||
sessions.get_token(conn, 3, {"email": "a@b.ro", "password": "parolaGRESITA"})
|
sessions.get_token(conn, 3, {"email": "a@b.ro", "password": "parolaGRESITA"})
|
||||||
|
|||||||
326
tests/test_worker_rar_env.py
Normal file
326
tests/test_worker_rar_env.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""Teste US-006 (PRD 5.20) — sesiuni si trimitere worker per (cont, env).
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
- AccountSessions re-cheiat pe (account_id, rar_env): doua env ale aceluiasi cont
|
||||||
|
au sesiuni distincte.
|
||||||
|
- RarClient creat cu base_url-ul mediului (test -> rar_base_url_test,
|
||||||
|
prod -> rar_base_url_prod), nu ancora globala.
|
||||||
|
- Creds extrase din slotul accounts.rar_creds_{env}_enc corect per env.
|
||||||
|
- Purjarea creds efemere scoped pe (account_id, rar_env): login pe test NU sterge
|
||||||
|
creds efemere ale submission-urilor PROD ale aceluiasi cont (auto-fix E1/1a).
|
||||||
|
- recover_orphans per (cont, env): orfanii prod reconciliati contra endpoint prod,
|
||||||
|
nu contra test (auto-fix 1b/E6).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture DB
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(monkeypatch):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
|
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
|
||||||
|
monkeypatch.setenv("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
|
||||||
|
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
|
||||||
|
from app.config import get_settings
|
||||||
|
from app import crypto
|
||||||
|
get_settings.cache_clear()
|
||||||
|
crypto.reset_cache()
|
||||||
|
from app.db import get_connection, init_db
|
||||||
|
init_db()
|
||||||
|
conn = get_connection()
|
||||||
|
settings = get_settings()
|
||||||
|
yield conn, settings
|
||||||
|
conn.close()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
crypto.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONTENT = {
|
||||||
|
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
|
||||||
|
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||||
|
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sub(conn, account_id=1, rar_env="test", creds_enc=None, status="queued"):
|
||||||
|
"""Insereaza un submission cu env si creds explicite."""
|
||||||
|
content = _CONTENT.copy()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions "
|
||||||
|
"(idempotency_key, account_id, status, payload_json, rar_env, rar_creds_enc) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(f"k-{os.urandom(4).hex()}", account_id, status, json.dumps(content), rar_env, creds_enc),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(conn, sid):
|
||||||
|
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
# Captura base_url-urilor clientilor creati de AccountSessions
|
||||||
|
_created_clients: list = []
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRarClient:
|
||||||
|
"""RarClient stub care captura base_url-ul pentru assertii."""
|
||||||
|
|
||||||
|
def __init__(self, settings=None, *, base_url=None, login_exc=None):
|
||||||
|
self.base_url = base_url
|
||||||
|
self._login_exc = login_exc
|
||||||
|
self.login_calls = 0
|
||||||
|
self.closed = False
|
||||||
|
_created_clients.append(self)
|
||||||
|
|
||||||
|
def login(self, email, password):
|
||||||
|
self.login_calls += 1
|
||||||
|
if self._login_exc:
|
||||||
|
raise self._login_exc
|
||||||
|
return f"TOK-{email}-{self.base_url}"
|
||||||
|
|
||||||
|
def get_nomenclator(self, token):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_sesiune_separata_per_env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_sesiune_separata_per_env(env, monkeypatch):
|
||||||
|
"""Doua submission-uri ale aceluiasi cont, env test + prod -> doua login-uri distincte.
|
||||||
|
|
||||||
|
Cheia sesiunii e (account_id, rar_env): sesiunile test si prod sunt independente.
|
||||||
|
"""
|
||||||
|
import app.worker.__main__ as w
|
||||||
|
|
||||||
|
_created_clients.clear()
|
||||||
|
monkeypatch.setattr(w, "RarClient", FakeRarClient)
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
# Cont secundar (contul 1 e default din schema)
|
||||||
|
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'Cont2')")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
sessions = w.AccountSessions(settings)
|
||||||
|
|
||||||
|
creds_test = {"email": "test@example.ro", "password": "ptest"}
|
||||||
|
creds_prod = {"email": "prod@example.ro", "password": "pprod"}
|
||||||
|
|
||||||
|
tok_test = sessions.get_token(conn, 2, creds_test, "test")
|
||||||
|
tok_prod = sessions.get_token(conn, 2, creds_prod, "prod")
|
||||||
|
|
||||||
|
# Doua login-uri distincte
|
||||||
|
assert len(_created_clients) == 2
|
||||||
|
assert _created_clients[0].login_calls == 1
|
||||||
|
assert _created_clients[1].login_calls == 1
|
||||||
|
|
||||||
|
# Tokenuri distincte (de la email-uri diferite)
|
||||||
|
assert tok_test != tok_prod
|
||||||
|
|
||||||
|
# Sesiunile active: doua intrari, ambele pt cont 2, env diferite
|
||||||
|
active = sessions.active()
|
||||||
|
assert len(active) == 2
|
||||||
|
envs_active = {env for _, env, _, _ in active}
|
||||||
|
assert envs_active == {"test", "prod"}
|
||||||
|
|
||||||
|
# Al doilea apel cu acelasi (cont, env) -> cache, NU re-login
|
||||||
|
tok_test2 = sessions.get_token(conn, 2, creds_test, "test")
|
||||||
|
assert tok_test2 == tok_test
|
||||||
|
assert len(_created_clients) == 2 # niciun client nou creat
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_base_url_dupa_submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_base_url_dupa_submission(env, monkeypatch):
|
||||||
|
"""Un submission prod foloseste rar_base_url_prod; un submission test foloseste rar_base_url_test."""
|
||||||
|
import app.worker.__main__ as w
|
||||||
|
|
||||||
|
_created_clients.clear()
|
||||||
|
monkeypatch.setattr(w, "RarClient", FakeRarClient)
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
sessions = w.AccountSessions(settings)
|
||||||
|
|
||||||
|
creds = {"email": "x@example.ro", "password": "pw"}
|
||||||
|
sessions.get_token(conn, 1, creds, "test")
|
||||||
|
sessions.get_token(conn, 1, creds, "prod")
|
||||||
|
|
||||||
|
urls = {c.base_url for c in _created_clients}
|
||||||
|
assert settings.rar_base_url_test in urls, f"URL test asteptat in {urls}"
|
||||||
|
assert settings.rar_base_url_prod in urls, f"URL prod asteptat in {urls}"
|
||||||
|
# Cele doua URL-uri trebuie sa fie diferite (sisteme RAR separate)
|
||||||
|
assert settings.rar_base_url_test != settings.rar_base_url_prod
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_creds_din_slotul_env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_creds_din_slotul_env(env, monkeypatch):
|
||||||
|
"""Cand submissions.rar_creds_enc lipseste, worker ia din accounts.rar_creds_{env}_enc.
|
||||||
|
|
||||||
|
Prod ia din rar_creds_prod_enc, nu din slotul test (auto-fix 1c/E8 + fallback per-env).
|
||||||
|
"""
|
||||||
|
import app.worker.__main__ as w
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
|
||||||
|
enc_test = encrypt_creds({"email": "test@rar.ro", "password": "ptest"})
|
||||||
|
enc_prod = encrypt_creds({"email": "prod@rar.ro", "password": "pprod"})
|
||||||
|
|
||||||
|
# Salveaza creds in ambele sloturi per-env
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET rar_creds_test_enc=?, rar_creds_prod_enc=? WHERE id=1",
|
||||||
|
(enc_test, enc_prod),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Fara creds efemere pe submission -> fallback la slotul per-env
|
||||||
|
creds_test = w._creds_from_account(conn, 1, "test")
|
||||||
|
creds_prod = w._creds_from_account(conn, 1, "prod")
|
||||||
|
|
||||||
|
assert creds_test is not None, "slotul test trebuia sa aiba creds"
|
||||||
|
assert creds_test["email"] == "test@rar.ro"
|
||||||
|
|
||||||
|
assert creds_prod is not None, "slotul prod trebuia sa aiba creds"
|
||||||
|
assert creds_prod["email"] == "prod@rar.ro"
|
||||||
|
|
||||||
|
# Crucialmente: prod NU ia creds din slotul test
|
||||||
|
assert creds_prod["email"] != creds_test["email"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_purge_creds_doar_pe_env (auto-fix E1/1a)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_purge_creds_doar_pe_env(env, monkeypatch):
|
||||||
|
"""Dupa login pe env=test, creds efemere ale submission-urilor PROD raman neatinse.
|
||||||
|
|
||||||
|
Scopul purjarii: WHERE account_id=? AND rar_env=?. Altfel un login TEST sterge
|
||||||
|
creds ale submission-urilor PROD -> prod blocat (auto-fix E1/1a).
|
||||||
|
"""
|
||||||
|
import app.worker.__main__ as w
|
||||||
|
from app.crypto import encrypt_creds
|
||||||
|
|
||||||
|
_created_clients.clear()
|
||||||
|
monkeypatch.setattr(w, "RarClient", FakeRarClient)
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
|
||||||
|
enc = encrypt_creds({"email": "u@rar.ro", "password": "pw"})
|
||||||
|
|
||||||
|
# Doua submission-uri ale aceluiasi cont: unul test, unul prod (ambele cu creds efemere)
|
||||||
|
sid_test = _insert_sub(conn, account_id=1, rar_env="test", creds_enc=enc)
|
||||||
|
sid_prod = _insert_sub(conn, account_id=1, rar_env="prod", creds_enc=enc)
|
||||||
|
|
||||||
|
sessions = w.AccountSessions(settings)
|
||||||
|
|
||||||
|
# Login pe env=test
|
||||||
|
sessions.get_token(conn, 1, {"email": "u@rar.ro", "password": "pw"}, "test")
|
||||||
|
|
||||||
|
# Creds efemere ale submission-ului TEST trebuie sterse (purjare normala)
|
||||||
|
row_test = _row(conn, sid_test)
|
||||||
|
assert row_test["rar_creds_enc"] is None, "creds test trebuiau sterse dupa login test"
|
||||||
|
|
||||||
|
# Creds efemere ale submission-ului PROD trebuie PASTRATE (nu sunt pentru env=test)
|
||||||
|
row_prod = _row(conn, sid_prod)
|
||||||
|
assert row_prod["rar_creds_enc"] is not None, \
|
||||||
|
"creds prod NU trebuiau sterse la login test (auto-fix E1/1a)"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# test_reconcile_pe_env_corect (auto-fix 1b/E6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_reconcile_pe_env_corect(env, monkeypatch):
|
||||||
|
"""Un orfan env=prod e reconciliat contra endpoint PROD, nu contra test.
|
||||||
|
|
||||||
|
auto-fix 1b/E6: recover_orphans filtreaza pe rar_env si foloseste clientul/token-ul
|
||||||
|
env-ului corect. Orfanii prod contra endpoint test -> no-match -> re-POST prod =
|
||||||
|
DUPLICAT real ireversibil.
|
||||||
|
"""
|
||||||
|
import app.worker.__main__ as w
|
||||||
|
|
||||||
|
conn, settings = env
|
||||||
|
|
||||||
|
# Submission prod orfan (sending de mult timp)
|
||||||
|
sid_prod = _insert_sub(conn, account_id=1, rar_env="prod", status="sending")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET sending_since=datetime('now', '-1 hour') WHERE id=?", (sid_prod,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Submission test orfan (de verificat ca NU e atins de recover_orphans(rar_env='prod'))
|
||||||
|
sid_test = _insert_sub(conn, account_id=1, rar_env="test", status="sending")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE submissions SET sending_since=datetime('now', '-1 hour') WHERE id=?", (sid_test,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Clientul prod fake — "gaseste" prezentarea prod la RAR
|
||||||
|
class FakeProdRar:
|
||||||
|
def __init__(self):
|
||||||
|
self.get_finalizate_calls = 0
|
||||||
|
|
||||||
|
def get_finalizate(self, token):
|
||||||
|
self.get_finalizate_calls += 1
|
||||||
|
return [{"id": 9999, "vin": "WVWZZZ1KZAW000123",
|
||||||
|
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}]
|
||||||
|
|
||||||
|
def post_prezentare(self, token, payload):
|
||||||
|
return {"id": 9999}
|
||||||
|
|
||||||
|
# Clientul test fake — nu gaseste nimic (sistemul test nu are prezentarea)
|
||||||
|
class FakeTestRar:
|
||||||
|
def __init__(self):
|
||||||
|
self.get_finalizate_calls = 0
|
||||||
|
|
||||||
|
def get_finalizate(self, token):
|
||||||
|
self.get_finalizate_calls += 1
|
||||||
|
return [] # nu e la RAR test
|
||||||
|
|
||||||
|
def post_prezentare(self, token, payload):
|
||||||
|
return {"id": 1111}
|
||||||
|
|
||||||
|
rar_prod = FakeProdRar()
|
||||||
|
rar_test = FakeTestRar()
|
||||||
|
|
||||||
|
# Apelam recover_orphans cu clientul PROD si env='prod' -> trebuie sa gaseasca orfanul prod
|
||||||
|
n = w.recover_orphans(conn, settings, rar_prod, "tok-prod", account_id=1, rar_env="prod")
|
||||||
|
assert n == 1, f"trebuia sa reconcilieze 1 orfan prod, a gasit {n}"
|
||||||
|
|
||||||
|
row_prod = _row(conn, sid_prod)
|
||||||
|
assert row_prod["status"] == "sent", f"orfanul prod trebuia marcat sent, e {row_prod['status']}"
|
||||||
|
assert row_prod["id_prezentare"] == 9999
|
||||||
|
|
||||||
|
# Submission-ul TEST nu trebuia atins de recover_orphans cu rar_env='prod'
|
||||||
|
row_test = _row(conn, sid_test)
|
||||||
|
assert row_test["status"] == "sending", \
|
||||||
|
f"orfanul test NU trebuia atins de recover cu env=prod, e {row_test['status']}"
|
||||||
|
|
||||||
|
# Confirmare ca clientul prod a interogat finalizate (reconciliere pe endpoint corect)
|
||||||
|
assert rar_prod.get_finalizate_calls == 1
|
||||||
|
# Clientul test NU trebuia folosit (recover_orphans cu env=prod NU atinge endpoint test)
|
||||||
|
assert rar_test.get_finalizate_calls == 0
|
||||||
Reference in New Issue
Block a user