feat(api): rar_credentials optional pe POST /v1/prezentari

Cand `rar_credentials` lipseste din cerere, submission-ul intra fara creds
efemere, iar worker-ul cade pe creds-urile RAR durabile ale contului
(accounts.rar_creds_enc). Identificarea contului ramane pe cheia API.
Trimiterea explicita a creds-urilor suprascrie creds-urile contului pe acea
cerere (back-compat: fluxul vechi ROAAUTO merge identic).

- models.py: rar_credentials: RarCredentials | None = None
- router.py: cripteaza creds doar daca exista (altfel creds_enc=NULL)
- worker NEATINS: avea deja fallback _creds_for(...) or _creds_from_account(...)

Pagina /integrare aliniata: exemplele cod (7 limbaje) + export Postman nu mai
includ rar_credentials in payload; nota noua explica modelul (creds pe cont,
optional in payload). README rescris compact + reflecta optionalitatea.

Test nou: enqueue fara creds -> submission fara creds efemere -> fallback pe
contul cu creds salvate. Suita: 673 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 13:39:53 +00:00
parent 0517ae59fb
commit 5dc963a02c
8 changed files with 188 additions and 375 deletions

477
README.md
View File

@@ -1,288 +1,111 @@
# Gateway RAR AUTOPASS
Gateway web (Python / FastAPI) care preia prezentarile de service si le declara la
**RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro
existenta (ROAAUTO). Sursa de adevar pentru contractul RAR este
[`docs/api-rar-contract.md`](docs/api-rar-contract.md).
Gateway web (Python / FastAPI) care preia prezentarile de service-auto si le declara la
**RAR AUTOPASS** (Legea 142/2023, OM 210/2024). Inlocuieste integrarea Visual FoxPro (ROAAUTO).
## Arhitectura pe scurt
Doua procese peste acelasi SQLite, care comunica prin tabela `submissions`:
Doua procese peste acelasi SQLite persistent:
- **API** (`app.main:app`) — dashboard web, API v1, signup/login, panou admin, `/healthz`, `/metrics`.
- **Worker** (`app.worker`) — login RAR, trimite prezentarile din coada, retry/backoff, heartbeat.
| Proces | Rol | Pornire |
|--------|-----|---------|
| **API** (`app.main:app`) | API v1 (`/v1/*`), dashboard web (`/`), `/healthz`, `/metrics`, import fisiere (`/v1/import/*`) | `uvicorn app.main:app` |
| **Worker** (`app.worker`) | login RAR + JWT, refresh nomenclator, trimite prezentarile din coada, retry/backoff, heartbeat | `python3 -m app.worker` |
Trimiterea catre RAR e **dezactivata implicit** (`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
Worker-ul ruleaza ca **proces separat** (nu task in API) — un worker mort nu trebuie sa
lase containerul "sanatos". Comunicarea API <-> worker se face exclusiv prin tabela
`submissions` din SQLite. Send-ul catre RAR este **dezactivat implicit**
(`AUTOPASS_WORKER_SEND_ENABLED=false`) — sigur pentru probe.
Sursa de adevar pentru contractul RAR: [`docs/api-rar-contract.md`](docs/api-rar-contract.md).
Progres + proces: [`docs/ROADMAP.md`](docs/ROADMAP.md).
## Cerinte
- Python 3.12+
- Dependintele din `requirements.txt`
## Pornire rapida
```bash
pip3 install -r requirements.txt
pip3 install -r requirements.txt # Python 3.12+
uvicorn app.main:app --reload --port 8010 # API (dashboard /, Swagger /docs)
python3 -m app.worker # worker (doar daca vrei sa procesezi coada)
```
(Optional, pentru deploy: Docker + Docker Compose — vezi sectiunea Docker.)
La prima pornire se creeaza schema SQLite si se face seed la nomenclatorul RAR — dashboard-ul si
maparile merg imediat, offline. Pentru testarea UI-ului si a importului **nu** ai nevoie de worker.
## Configurare
Dev rapid fara login: porneste cu `AUTOPASS_WEB_AUTH_REQUIRED=false` (dashboard pe contul implicit id=1).
Variabilele de mediu folosesc prefixul `AUTOPASS_`. Pentru dev local valorile implicite
sunt suficiente — **nu** ai nevoie de `.env` sau de credentiale RAR ca sa testezi UI-ul si
API-ul. Copiaza `.env.example` -> `.env` doar cand vrei sa rulezi end-to-end.
| Variabila | Implicit | Rol |
|-----------|----------|-----|
| `AUTOPASS_DB_PATH` | `./data/autopass.db` | calea fisierului SQLite |
| `AUTOPASS_RAR_ENV` | `test` | `test` sau `prod` |
| `AUTOPASS_REQUIRE_API_KEY` | `false` | `false` = dev (fara cheie -> cont id=1); `true` = prod (cere cheie) |
| `AUTOPASS_CREDS_KEY` | (efemera) | cheie Fernet pt criptarea creds RAR. **Trebuie partajata intre API si worker.** Genereaza: `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
| `AUTOPASS_WORKER_SEND_ENABLED` | `false` | `true` = trimite efectiv la RAR (proba end-to-end) |
| `AUTOPASS_WORKER_USE_TEST_CREDS` | `false` | dev: foloseste blocul `<test>` din `settings.xml` pt login worker |
Pentru proba reala cu RAR: copiaza `settings.xml.example` -> `settings.xml` si completeaza
credentialele de test (fisierul **nu** se comite).
## Rulare locala (dezvoltare)
### 1. Porneste API-ul
### Cu `start.sh` (ambaleaza mediu + rol)
```bash
uvicorn app.main:app --reload --port 8010
# sau, daca uvicorn nu e pe PATH:
python3 -m uvicorn app.main:app --reload --port 8010
./start.sh test both --send # API + worker, trimite la RAR test (loguri in .run/)
./start.sh test finalizate # listeaza prezentarile inregistrate la RAR (verificare independenta)
./start.sh status # stare procese + /healthz
./start.sh stop # opreste procesele pornite cu "both"
./start-test.sh / ./start-prod.sh # fixeaza mediul (test/prod), forwardeaza rolul
```
La prima pornire se creeaza schema SQLite si se face seed la nomenclatorul RAR (18 coduri
din contract), astfel incat dashboard-ul si maparile functioneaza imediat, offline.
### 2. (Optional) Porneste worker-ul
Necesar doar pentru a procesa coada / a trimite la RAR. Pentru testarea UI-ului si a
import-ului **nu** e necesar.
```bash
python3 -m app.worker
```
### Pornire rapida cu `start.sh`
`start.sh` ambaleaza pornirea pe mediu (`test` / `prod`) si rol (`api` / `worker` / `both`):
```bash
./start.sh test api # API pe :8010, mediu test
./start.sh test worker --send # worker care TRIMITE la RAR test
./start.sh test both --send # API + worker impreuna (dev end-to-end, loguri in .run/)
./start.sh prod api --port 8010 # API mediu prod
./start.sh prod worker --send # worker prod (NU foloseste creds de test)
./start.sh status # stare procese + /healthz
./start.sh stop # opreste procesele pornite cu "both"
./start.sh test finalizate # ce prezentari sunt inregistrate la RAR (vezi mai jos)
```
Optiuni: `--port N`, `--host H`, `--reload` (dev), `--send` (activeaza trimiterea la RAR),
`--test-creds` / `--no-test-creds` (forteaza folosirea creds `<test>` din `settings.xml`).
Pe `test` cu `--send`, creds `<test>` se folosesc automat. Pentru productie reala foloseste
`docker compose` (vezi sectiunea Docker).
Doua wrappere fixeaza mediul si forwardeaza rolul + optiunile:
```bash
./start-test.sh # = start.sh test both --send (API + worker, trimite la RAR test)
./start-test.sh worker --send # = start.sh test worker --send
./start-test.sh finalizate # = start.sh test finalizate
./start-prod.sh both --send # = start.sh prod both --send
./start-prod.sh api # = start.sh prod api
```
Pe test, `./start-test.sh` fara argumente porneste end-to-end (sandbox RAR e sigur). Pe prod,
`./start-prod.sh` cere rolul explicit si trimiterea trebuie ceruta cu `--send` (evita trimiteri
accidentale in productie).
## Testare in browser
Cu API-ul pornit, deschide in browser:
## Pagini web
| URL | Ce vezi |
|-----|---------|
| `http://localhost:8010/` | **Dashboard** — stare coada, banner prezentari blocate, stare worker / ultim login RAR, editor mapari operatii, browser nomenclator, sectiune **import fisier** |
| `http://localhost:8010/docs` | **Swagger UI** — API v1 interactiv (incearca endpointurile direct din browser) |
| `http://localhost:8010/healthz` | JSON sanatate: worker viu, ultim login RAR, adancime coada |
| `http://localhost:8010/metrics` | metrici text (submissions pe status) |
| `/` | Dashboard: coada, prezentari blocate, stare worker, import fisier, mapari, nomenclator |
| `/signup` · `/login` | Inregistrare cont (emite cheia API o data) · autentificare |
| `/admin` | Panou admin: conturi pe stari, activare/blocare/arhivare (doar admini) |
| `/integrare` | Exemple cod (Python/C#/Node/VFP), export Postman/OpenAPI, testeaza conexiunea |
| `/docs` | Swagger UI — API v1 interactiv |
| `/healthz` · `/metrics` | sanatate JSON · metrici text |
### Fluxul de import fisier (xlsx / csv) din browser
## Import fisier (xlsx / csv)
Pe dashboard, in sectiunea de import:
Pe dashboard: **incarca** fisierul → **mapeaza coloanele** (sugerate automat fuzzy; maparea se retine
pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_mapping` / `needs_data` /
`already_sent` / ...) → **confirma** (retastezi numarul de randuri `ok`). Randurile intra in coada.
1. **Incarca** un fisier `.xlsx` sau `.csv` (drag & drop sau selectare).
2. **Mapeaza coloanele** — gateway-ul sugereaza automat (fuzzy) maparea coloana fisier ->
camp canonic (VIN, data prestatie, odometru, operatie etc.). Maparea se retine pe
semnatura coloanelor: la urmatorul fisier cu aceleasi coloane se aplica automat.
3. **Preview** — fiecare rand primeste o stare: `ok`, `needs_mapping`, `needs_data`,
`needs_review`, `already_sent`, `duplicate_in_file`.
4. **Confirma** — gate dur: retastezi numarul exact de randuri `ok` de trimis. Randurile
confirmate intra in coada (`submissions`), apoi le urmaresti in tabelul de jos.
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`,
`Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate.
Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`,
`Odometru final`, `Odometru initial`, `Operatie`, `Observatii`.
## API v1 (curl)
### Genereaza un fisier de test pentru import
Repo-ul nu contine fisiere sample. Creeaza unul rapid:
Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`.
```bash
python3 - <<'PY'
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"])
ws.append(["WAUZZZ8K0AA000001", "B123ABC", "2026-06-15", 120000, "REVIZIE PERIODICA"])
ws.append(["WAUZZZ8K0AA000002", "B456DEF", "2026-06-16", 85000, "REPARATIE"])
wb.save("sample_import.xlsx")
print("scris sample_import.xlsx")
PY
curl -s http://localhost:8010/healthz | python3 -m json.tool # sanatate
curl -s http://localhost:8010/v1/nomenclator # coduri RAR (cache local)
curl -s http://localhost:8010/v1/prezentari # coada
# Trimite o prezentare. `rar_credentials` e OPTIONAL: daca lipseste, worker-ul
# foloseste creds-urile RAR salvate pe cont (POST /v1/conturi/rar-creds). Trimite-le
# explicit doar cand vrei sa le suprascrii pe acea cerere.
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
-d '{
"prezentari": [{
"vin": "WAUZZZ8K0AA000001", "nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15", "odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
# Dry-run: valideaza payload + mapare, FARA enqueue, FARA creds
curl -s -X POST http://localhost:8010/v1/prezentari/valideaza \
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' -d '{ "prezentari": [ ... ] }'
# Import fisier
curl -s -X POST http://localhost:8010/v1/import -H 'X-API-Key: rfak_...' -F 'file=@import.xlsx'
```
Sau un CSV echivalent:
Toate endpointurile sunt in `/docs`. Exemple gata facute + Postman/OpenAPI: hub-ul `/integrare`.
## Conturi si chei API
Fiecare service = un **cont** (`accounts`) cu lifecycle (`pending → active → blocked / archived / deleted`).
Worker-ul trimite doar pentru conturi `active`. Web-ul se autentifica prin **sesiune** (login email+parola),
API-ul prin **cheie API** (`X-API-Key`). Cheia identifica contul, e separata de credentialele RAR.
```bash
printf 'VIN,Nr inmatriculare,Data prestatie,Odometru final,Operatie\nWAUZZZ8K0AA000001,B123ABC,2026-06-15,120000,REVIZIE PERIODICA\n' > sample_import.csv
# Self-onboarding: service-ul deschide /signup → primeste cheia o data. Primul cont = admin.
# Sau din CLI (admin, pe masina gateway-ului):
python3 -m tools.account create --name "Service Auto SRL" --cui RO12345678 --with-key
python3 -m tools.account list [--pending] | activate --account N | set-admin --account N
python3 -m tools.apikey create|list|rotate|revoke --account N # cheie afisata O SINGURA DATA
```
Incarca apoi fisierul prin sectiunea de import a dashboard-ului.
## Proba trimitere la RAR (mediu test) + verificare ca au ajuns
Implicit worker-ul **nu** trimite (`AUTOPASS_WORKER_SEND_ENABLED=false`). Pentru proba
end-to-end pe contul de test RAR:
1. Pune credentialele de test in `settings.xml` (copiaza din `settings.xml.example`,
completeaza blocul `<test>`). Acestea **nu** se comit.
2. Bag-a prezentari in coada — fie prin import fisier din dashboard, fie prin API
(`POST /v1/prezentari`, vezi mai jos).
3. Porneste worker-ul cu trimiterea activa:
```bash
./start.sh test worker --send
```
Worker-ul face login la RAR test, ia randurile `queued`, trimite si trece fiecare rand
in `sent` cu `id_prezentare` (id-ul intors de RAR — dovada ca a ajuns) sau in
`needs_data` / `error` cu motivul.
4. **Vizualizeaza prezentarile trimise** — trei feluri:
- **Dashboard** (`http://localhost:8010/`) — tabelul de jos arata fiecare submission cu
status (`sent`/`error`/...), `id_prezentare`, cod RAR si eroare. Se actualizeaza singur.
- **API**: `curl -s http://localhost:8010/v1/prezentari` — coada locala cu statusuri.
- **Direct de la RAR** (confirmare independenta ca au ajuns):
```bash
./start.sh test finalizate
```
Face login la RAR test si listeaza prezentarile inregistrate acolo (id, VIN, data,
odometru). Compari `id`-urile cu `id_prezentare` din coada locala: daca se regasesc,
prezentarea a ajuns la RAR.
> Status `sent` + `id_prezentare` completat = RAR a acceptat prezentarea. Worker-ul are si
> reconciliere anti-duplicat: daca raspunsul RAR se pierde, la urmatorul ciclu cauta
> prezentarea in finalizate si o marcheaza `sent` fara a o re-trimite.
## Import fisier pentru mai multi utilizatori (service-uri) cu formate diferite
Da — fiecare service auto poate avea propriul format de fisier (alte denumiri de coloane,
alta ordine, alt format de data). Sistemul **tine minte maparea per cont**, deci nu o refaci
la fiecare upload:
- **Cont (`account_id`)** — fiecare service e un cont. In productie contul se identifica prin
**cheia API** (`X-API-Key`) trimisa la upload/cerere (`AUTOPASS_REQUIRE_API_KEY=true`). In
dev, fara cheie, totul merge pe contul implicit `id=1`.
- **Semnatura coloanelor** — la upload, gateway-ul calculeaza o semnatura din lista (sortata)
a denumirilor de coloane din fisier. Maparea coloana-fisier -> camp-canonic se salveaza in
tabela `column_mappings`, cheie unica `(account_id, signature_coloane)`, impreuna cu
formatul de data.
- **Re-aplicare automata** — la urmatorul fisier cu **aceleasi coloane** (aceeasi semnatura),
pentru **acelasi cont**, maparea retinuta se aplica automat si sari direct la preview. Daca
un service schimba formatul (alte coloane) se creeaza o semnatura noua, deci o mapare noua —
fara sa o strice pe cea veche. Astfel un cont poate avea mai multe formate memorate simultan.
Pe scurt: **cine** = `account_id` (din cheia API), **care format** = `signature_coloane`
(setul de coloane al fisierului). Combinatia lor selecteaza maparea corecta.
## Conturi (service-uri) si chei API
Un **cont** (`accounts`) = un service auto care foloseste gateway-ul. Cererile `/v1/*` se
autentifica printr-o **cheie API** (header `X-API-Key: <cheie>` sau `Authorization: Bearer
<cheie>`) care identifica contul. Cheia e separata de credentialele RAR ale service-ului.
Enforcement-ul e controlat de `AUTOPASS_REQUIRE_API_KEY`:
- `false` (dev/test, implicit): cerere fara cheie -> contul implicit `id=1`; o cheie prezenta
dar invalida -> `401`.
- `true` (productie): orice `/v1/*` **protejat** cere o cheie valida, altfel `401`.
Auth-ul se aplica pe endpointurile care scriu/sunt legate de cont (au dependinta de cheie):
`POST /v1/prezentari`, `POST /v1/mapari`, `POST|DELETE /v1/conturi/rar-creds` si toate rutele
de import (`POST /v1/import`, `.../column-mapping`, `.../preview`, `.../commit`,
`.../export-failed`) — acestea ruleaza pe `account_id`-ul cheii. GET-urile de **monitorizare**
(`/v1/prezentari`, `/v1/prezentari/{id}`, `/v1/nomenclator`, `/v1/mapari`, `/v1/audit/export`)
sunt momentan **neprotejate si globale** (nu filtreaza pe cont). Filtrarea pe cont a listarilor
+ protejarea lor raman de adaugat (vezi tabelul de mai jos).
### Stare implementare
| Capabilitate | Stare | Cum |
|--------------|-------|-----|
| Emitere / rotire / revocare / listare chei API | **Implementat** | CLI `python3 -m tools.apikey` |
| Auth pe cheie (X-API-Key / Bearer) pe POST-uri + import | **Implementat** | `app/auth.py` + flag `AUTOPASS_REQUIRE_API_KEY` |
| Ingestie + import account-scoped (din cheie) | **Implementat** | `POST /v1/prezentari`, `POST /v1/import` |
| Creds RAR durabile per cont | **Implementat** | `POST /v1/conturi/rar-creds` |
| Creare cont nou (service) | **De facut / manual** | momentan prin `INSERT` SQL (vezi mai jos); nu exista tool/endpoint dedicat |
| Protejare + filtrare pe cont a GET-urilor de listare | **De facut** | `GET /v1/prezentari`, `/v1/nomenclator`, `/v1/audit/export` sunt globale acum |
| Self-onboarding web (login email+parola -> emite cheie) | **De facut** | `docs/ROADMAP.md` (Etapa 3.3) — neimplementat |
> Lifecycle-ul cheilor se face DOAR din CLI, pe masina gateway-ului (admin) — nu exista
> suprafata HTTP de administrare de securizat. Cheia in clar se afiseaza **o singura data**
> la creare/rotire; in DB se pastreaza doar hash-ul SHA-256.
### Creare cont + cheie pentru un service nou
Pana la onboarding-ul web, un cont nou se creeaza direct in DB, apoi i se emite o cheie:
```bash
# 1. Creeaza contul (numele + CUI sunt informative)
python3 -c "
from app.db import get_connection, init_db
init_db()
c = get_connection()
cur = c.execute(\"INSERT INTO accounts (name, cui) VALUES ('Service Auto SRL', 'RO12345678')\")
print('account_id nou =', cur.lastrowid); c.commit(); c.close()
"
# 2. Emite o cheie API pentru cont (afisata O SINGURA DATA)
python3 -m tools.apikey create --account 2
# -> rfak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Alte operatii
python3 -m tools.apikey list # toate cheile
python3 -m tools.apikey list --account 2 # cheile unui cont
python3 -m tools.apikey rotate --account 2 # revoca cele active + emite una noua
python3 -m tools.apikey revoke --key-id 3 # revoca o cheie dupa id
```
### Creds RAR per cont
Ca worker-ul sa poata trimite pentru un service fara ca fiecare cerere sa-i poarte parola
RAR, seteaza credentialele RAR durabile pe cont (criptate Fernet at-rest):
**Creds RAR per cont** (ca worker-ul sa trimita fara parola in fiecare cerere) — criptate Fernet at-rest:
```bash
curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \
@@ -290,126 +113,66 @@ curl -s -X POST http://localhost:8010/v1/conturi/rar-creds \
-d '{"email": "service@exemplu.ro", "password": "parola-rar"}'
```
## Testare prin API (curl)
> GET-urile de listare (`/v1/prezentari`, `/v1/nomenclator`, `/v1/audit/export`) sunt momentan
> **globale si neprotejate** — filtrarea pe cont ramane de adaugat.
Exemplele de mai jos arata atat varianta **dev** (fara cheie -> cont id=1), cat si varianta
**service cu cheie API** (header `X-API-Key`). Cand `AUTOPASS_REQUIRE_API_KEY=true`, cheia e
obligatorie.
## Proba reala la RAR (mediu test)
1. Pune creds de test in `settings.xml` (copiaza din `settings.xml.example`, bloc `<test>`; **nu** se comite).
`settings.xml` tine un singur cont RAR doar pentru dev/test — creds-urile conturilor reale stau criptate in DB.
2. Baga prezentari in coada (import sau API).
3. `./start.sh test worker --send` — worker-ul trimite si trece fiecare rand in `sent` (cu `id_prezentare`),
`needs_data` sau `error`.
4. Verifica: dashboard, `curl /v1/prezentari`, sau `./start.sh test finalizate` (listeaza direct de la RAR).
> `sent` + `id_prezentare` = RAR a acceptat. La raspuns pierdut, worker-ul reconciliaza anti-duplicat
> (cauta in finalizate, marcheaza `sent` fara re-trimitere). `FINALIZATA` e terminal la RAR.
## Configurare (`AUTOPASS_*`)
| Variabila | Implicit | Rol |
|-----------|----------|-----|
| `DB_PATH` | `./data/autopass.db` | calea SQLite |
| `RAR_ENV` | `test` | `test` / `prod` |
| `REQUIRE_API_KEY` | `false` | `true` = cere cheie pe `/v1/*` (prod) |
| `WEB_AUTH_REQUIRED` | `true` | `false` = dashboard fara login, cont id=1 (dev) |
| `CREDS_KEY` | (efemera) | **cheie Fernet creds RAR — trebuie PARTAJATA intre API si worker** |
| `SESSION_SECRET` | (efemer) | secret cookie sesiune; persistent in prod |
| `WORKER_SEND_ENABLED` | `false` | `true` = trimite efectiv la RAR |
| `SMTP_HOST` (+ `_PORT`/`_USER`/`_PASSWORD`/`_FROM`) | (none) | notificare admin la signup (best-effort) |
Genereaza chei: `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
(CREDS_KEY) si `python3 -c "import secrets; print(secrets.token_hex(32))"` (SESSION_SECRET).
## Teste
```bash
# Sanatate (neprotejat)
curl -s http://localhost:8010/healthz | python3 -m json.tool
# Nomenclator RAR (cache local)
curl -s http://localhost:8010/v1/nomenclator
# Coada de prezentari (monitorizare; momentan globala + neprotejata, vezi nota de mai sus)
curl -s http://localhost:8010/v1/prezentari
# Trimite o prezentare -- dev (fara cheie API -> cont id=1)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "test@example.ro", "password": "secret"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15",
"odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
# Trimite o prezentare -- service cu cheie API (account_id curge din cheie)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'X-API-Key: rfak_...' -H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "service@exemplu.ro", "password": "parola-rar"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000002",
"nr_inmatriculare": "B456DEF",
"data_prestatie": "2026-06-16",
"odometru_final": "85000",
"prestatii": [{"cod_op_service": "REPARATIE", "denumire": "REPARATIE"}]
}]
}'
# Import fisier prin API pentru un service (multi-tenant: contul vine din cheie)
curl -s -X POST http://localhost:8010/v1/import \
-H 'X-API-Key: rfak_...' -F 'file=@sample_import.xlsx'
python3 -m pytest -q # toata suita
python3 -m pytest tests/test_x.py -q # un fisier
```
Endpointurile complete sunt vizibile si testabile in `/docs` (Swagger UI). In Swagger,
pune cheia prin butonul "Authorize" sau adauga header-ul `X-API-Key`.
```bash
# Sanatate
curl -s http://localhost:8010/healthz | python3 -m json.tool
# Nomenclator RAR (cache local)
curl -s http://localhost:8010/v1/nomenclator
# Coada de prezentari
curl -s http://localhost:8010/v1/prezentari
# Trimite o prezentare (dev: fara cheie API -> cont id=1)
curl -s -X POST http://localhost:8010/v1/prezentari \
-H 'Content-Type: application/json' \
-d '{
"rar_credentials": {"email": "test@example.ro", "password": "secret"},
"prezentari": [{
"vin": "WAUZZZ8K0AA000001",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-15",
"odometru_final": "120000",
"prestatii": [{"cod_op_service": "REVIZIE PERIODICA", "denumire": "REVIZIE PERIODICA"}]
}]
}'
```
Endpointurile complete sunt vizibile si testabile in `/docs` (Swagger UI).
## Rularea testelor
```bash
python3 -m pytest -q
```
Suita acopera fundatia, securitatea, validarea, parserul de import, masina de stari a
worker-ului si fluxul UI de import (E2E cu RAR mock).
## Docker / deploy
```bash
# 1. Pregateste .env (CRITIC: AUTOPASS_CREDS_KEY partajata intre api si worker)
cp .env.example .env
# completeaza AUTOPASS_CREDS_KEY (vezi comanda de generare de mai sus)
# 2. Porneste API + worker + autoheal
docker compose up --build
cp .env.example .env # CRITIC: completeaza AUTOPASS_CREDS_KEY (partajata api+worker)
docker compose up --build # api (:8010) + worker + autoheal, acelasi image + volum SQLite
```
`docker-compose.yml` porneste trei containere: `api` (port 8010), `worker` si `autoheal`
(restarteaza worker-ul cand heartbeat-ul devine invechit). Ambele servicii folosesc acelasi
image si acelasi volum SQLite persistent.
## Structura
```
app/
main.py # FastAPI: API v1 + dashboard + /healthz + /metrics
api/v1/ # router.py (prezentari, nomenclator, mapari) + import_router.py
web/ # routes.py (dashboard + import UI HTMX) + templates/ + static/
main.py # FastAPI: API v1 + dashboard + auth + admin
api/v1/ # router.py (prezentari, valideaza, nomenclator, mapari, conturi),
# import_router.py, integrare_router.py (ping, postman/openapi)
web/ # routes.py (dashboard + import HTMX), auth_routes.py, admin_routes.py,
# session.py, csrf.py, labels.py, templates/, static/
worker/ # proces separat: login RAR, send, retry, heartbeat
rar_client.py # client HTTP RAR (login/JWT, postPrezentare, nomenclator)
validation.py # validare continut (T3)
mapping.py # mapare operatie -> cod prestatie + fuzzy lookup
crypto.py # criptare Fernet creds RAR efemere (zero-storage at rest)
auth.py users.py accounts.py # chei API, parole scrypt + admin, lifecycle conturi
validation.py mapping.py errors.py crypto.py # validare, mapare cod, erori 3-niveluri, Fernet
schema.sql # schema SQLite
docs/ # contract RAR (sursa de adevar) + ROADMAP (progres + proces)
tests/ # suita pytest
legacy-vfp/ # arhiva Visual FoxPro ROAAUTO (legacy, doar referinta/migrare)
tools/ # CLI admin: account, apikey, backup, rar_finalizate, import_dbf
docs/ # contract RAR + ROADMAP + prd/
tests/ legacy-vfp/ # suita pytest · arhiva ROAAUTO (referinta)
```
Contract RAR (sursa de adevar): [`docs/api-rar-contract.md`](docs/api-rar-contract.md).
Roadmap + proces de dezvoltare: [`docs/ROADMAP.md`](docs/ROADMAP.md).

View File

@@ -94,12 +94,10 @@ _POSTMAN_ITEMS = [
"body": {
"mode": "raw",
"options": {"raw": {"language": "json"}},
# rar_credentials e optional: cererea trimite doar cheia API + datele
# prezentarii; worker-ul foloseste creds-urile RAR salvate pe cont.
"raw": (
'{\n'
' "rar_credentials": {\n'
' "email": "user@exemplu.ro",\n'
' "password": "parola_rar"\n'
' },\n'
' "prezentari": [\n'
' {\n'
' "vin": "WVWZZZ1KZAW000123",\n'

View File

@@ -60,12 +60,15 @@ def create_prezentari(
implicit id=1 in dev fara cheie, 401 fara cheie valida in prod.
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`).
"""
acct = account_or_default(account_id)
# Creds RAR efemere: criptate si lipite de fiecare submission nou pana la
# primul login reusit pentru cont (worker le sterge atunci). Zero-storage at
# rest — niciodata in clar in DB/loguri (plan sect. 5).
creds_enc = encrypt_creds(req.rar_credentials.model_dump())
# rest — niciodata in clar in DB/loguri (plan sect. 5). Optional: cand lipsesc,
# creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului.
creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None
conn = get_connection()
results: list[SubmissionResult] = []
try:

View File

@@ -80,9 +80,14 @@ class PrezentareIn(BaseModel):
class PrezentareRequest(BaseModel):
"""Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR."""
"""Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR.
rar_credentials: RarCredentials
`rar_credentials` e OPTIONAL: daca lipseste, worker-ul foloseste creds-urile RAR
durabile salvate pe cont (`accounts.rar_creds_enc`, via POST /v1/conturi/rar-creds).
Trimite-le explicit doar cand vrei sa suprascrii creds-urile contului pe acea cerere.
"""
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)

View File

@@ -27,6 +27,10 @@ def _payload_prezentari_dict(account_id: int) -> dict:
Campurile cu default (odometru_initial, obs, b64_image, sistem_reparat) sunt
omise pentru concizie — nu sunt obligatorii.
`rar_credentials` NU e inclus: cererea trimite doar cheia API + datele prezentarii,
iar worker-ul foloseste credentialele RAR salvate pe cont (tab-ul Cont). Trimiterea
lor in payload e optionala (suprascrie creds-urile contului pe acea cerere).
"""
# Construim un dict cu toate campurile obligatorii
campuri = _campuri_obligatorii()
@@ -48,13 +52,7 @@ def _payload_prezentari_dict(account_id: int) -> dict:
# Fallback generic pentru campuri neasteptate adaugate ulterior
prezentare[camp] = f"<{camp}>"
return {
"rar_credentials": {
"email": "utilizator@service.ro",
"password": "parola_rar",
},
"prezentari": [prezentare],
}
return {"prezentari": [prezentare]}
def _payload_json_str(account_id: int, indent: int = 2) -> str:

View File

@@ -26,6 +26,11 @@
<span class="muted" style="font-size:13px; margin-left:16px;">Endpoint:</span>
<code style="font-size:12px; color:var(--accent);">{{ base_url }}</code>
</div>
<p class="muted" style="font-size:12px; margin:10px 0 0;">
Cererile trimit doar cheia API + datele prezentarii. Credentialele RAR se configureaza
o data in <a href="/?tab=cont">Cont</a> si sunt folosite automat la trimitere. Optional,
poti include <code>rar_credentials</code> in payload ca sa le suprascrii pe acea cerere.
</p>
</div>
{# Tab-list PRIMAR: limbaje #}

View File

@@ -109,6 +109,41 @@ def test_ingestie_stocheaza_creds_criptate(env):
assert decrypt_creds(row["rar_creds_enc"]) == {"email": "x@y.ro", "password": "SECRETPW"}
def test_ingestie_fara_creds_foloseste_contul(env):
"""POST /v1/prezentari fara rar_credentials -> submission fara creds efemere;
worker-ul cade pe creds-urile durabile ale contului (accounts.rar_creds_enc)."""
import app.worker.__main__ as w
from app.db import get_connection
from app.main import app
with TestClient(app) as c:
# Contul (id=1 in dev) isi salveaza creds RAR durabile o data.
r0 = c.post("/v1/conturi/rar-creds", json={"email": "web@y.ro", "password": "WEBPW"})
assert r0.status_code == 200
# Trimitere FARA rar_credentials (doar payload). Identificarea contului
# ramane pe API key / sesiune; creds RAR nu mai sunt necesare in cerere.
body = {"prezentari": [{
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}]}
r = c.post("/v1/prezentari", json=body)
assert r.status_code == 200, r.text
sid = r.json()["results"][0]["submission_id"]
conn = get_connection()
try:
row = conn.execute("SELECT rar_creds_enc FROM submissions WHERE id=?", (sid,)).fetchone()
# Submission-ul nu poarta creds efemere...
assert row["rar_creds_enc"] is None
# ...iar lantul de rezolvare al worker-ului ia creds din cont.
creds = w._creds_for({"creds_enc": None}, w.get_settings()) or w._creds_from_account(conn, 1)
assert creds == {"email": "web@y.ro", "password": "WEBPW"}
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Worker: sesiuni per-cont #
# --------------------------------------------------------------------------- #

View File

@@ -112,9 +112,15 @@ def test_payload_acopera_campurile_obligatorii_din_model():
for camp in obligatorii:
assert camp in snippet, f"camp obligatoriu absent din snippet: {camp}"
# Credentiale RAR (email + password)
assert "email" in snippet, "camp 'email' absent din snippet (RarCredentials)"
assert "password" in snippet, "camp 'password' absent din snippet (RarCredentials)"
def test_payload_nu_include_credentiale_rar():
"""rar_credentials e OPTIONAL: snippet-ul exemplu trimite doar cheia API + datele
prezentarii (creds-urile RAR se configureaza pe cont, nu in fiecare cerere)."""
for limbaj in LIMBAJE_OBLIGATORII:
snippet = RESULT[limbaj]["prezentari"]
assert "rar_credentials" not in snippet, (
f"{limbaj}.prezentari include rar_credentials — nu mai e necesar in payload"
)
def test_prestatii_in_snippet_are_cod():