diff --git a/README.md b/README.md index 0fa0585..4f6810d 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 `` din `settings.xml`). -Pe `test` cu `--send`, creds `` 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 ``). 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: ` sau `Authorization: Bearer -`) 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 ``; **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). diff --git a/app/api/v1/integrare_router.py b/app/api/v1/integrare_router.py index 42f83fc..fa3c55f 100644 --- a/app/api/v1/integrare_router.py +++ b/app/api/v1/integrare_router.py @@ -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' diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 46a581d..b74f41f 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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: diff --git a/app/models.py b/app/models.py index 7397c51..624ad45 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/web/integrare_examples.py b/app/web/integrare_examples.py index c133c49..7725faa 100644 --- a/app/web/integrare_examples.py +++ b/app/web/integrare_examples.py @@ -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: diff --git a/app/web/templates/_integrare.html b/app/web/templates/_integrare.html index 857b74e..8e75de8 100644 --- a/app/web/templates/_integrare.html +++ b/app/web/templates/_integrare.html @@ -26,6 +26,11 @@ Endpoint: {{ base_url }} +

+ Cererile trimit doar cheia API + datele prezentarii. Credentialele RAR se configureaza + o data in Cont si sunt folosite automat la trimitere. Optional, + poti include rar_credentials in payload ca sa le suprascrii pe acea cerere. +

{# Tab-list PRIMAR: limbaje #} diff --git a/tests/test_creds_delivery.py b/tests/test_creds_delivery.py index 35e5bd5..b8ae599 100644 --- a/tests/test_creds_delivery.py +++ b/tests/test_creds_delivery.py @@ -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 # # --------------------------------------------------------------------------- # diff --git a/tests/test_integrare_examples.py b/tests/test_integrare_examples.py index 08eee5a..3474fc5 100644 --- a/tests/test_integrare_examples.py +++ b/tests/test_integrare_examples.py @@ -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():