feat: schelet gateway FastAPI (API v1 + worker + dashboard + SQLite WAL)

Structura repo conform plan.md sect. 4, booteaza cu /healthz verde:
- app/main.py: FastAPI (lifespan init_db), /healthz (worker viu + last login + queue), /metrics
- app/api/v1: POST /v1/prezentari (enqueue + dedup idempotency UNIQUE), GET prezentari/{id}, nomenclator, mapari
- app/rar_client.py: client RAR real (login/JWT, nomenclator, postPrezentare, getFinalizate) cu User-Agent obligatoriu (fix WAF 403)
- app/worker: proces separat, claim atomic BEGIN IMMEDIATE, heartbeat, login+send (send dezactivat by default)
- app/web: dashboard Jinja2+HTMX (coada, banner alerta blocate, worker viu/mort, stari empty)
- app/db.py + schema.sql: SQLite WAL, tabele accounts/api_keys/operations_mapping/nomenclator_rar/submissions/worker_heartbeat
- app/idempotency.py + payload.py: hash continut canonic + builder payload (status FINALIZATA, fara tipPrestatie)
- Dockerfile + docker-compose.yml (api+worker, volum SQLite persistent, restart:always)
- tools/import_dbf.py: stub T5

Verificat live: login prin rar_client OK (token 259), nomenclator 18 coduri, worker heartbeat -> /healthz worker_alive=True.
Ramas: T3 validare Pydantic, T4 snapshot payload, T2 reconciliere/retry worker, T5 import DBF, auth API-key, middleware redactare creds, criptare PII.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-15 12:04:11 +00:00
parent ff03041cd6
commit f1b5f1f80f
26 changed files with 1145 additions and 0 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
.git
.gitignore
__pycache__/
*.py[cod]
.venv/
venv/
*.db
*.db-wal
*.db-shm
data/
docs/
*.prg
*.PRG
*.DBF
*.CDX
*.FPT
*.pjx
*.PJT
settings.xml
.svn/
.gstack/

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Gateway RAR AUTOPASS — imagine unica (API + worker ruleaza ca servicii separate
# din acelasi image, vezi docker-compose.yml).
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tools ./tools
# Date persistente (SQLite WAL) pe volum montat.
ENV AUTOPASS_DB_PATH=/data/autopass.db
VOLUME ["/data"]
EXPOSE 8000
# Default = API. Worker-ul suprascrie command in compose.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

7
app/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Gateway RAR AUTOPASS — pachet aplicatie.
Migrare ROAAUTO (Visual FoxPro) la un gateway central FastAPI care declara
prestatii la RAR AUTOPASS. Vezi docs/plans/plan.md si docs/api-rar-contract.md.
"""
__version__ = "0.1.0"

0
app/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

127
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,127 @@
"""API v1 — suprafata gateway (schelet).
Endpointuri din plan.md sect. 4. In schelet:
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
- GET /v1/nomenclator: cache local.
- GET /v1/mapari: listare mapari cont.
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, HTTPException
from ...db import get_connection
from ...idempotency import idempotency_key
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
router = APIRouter(prefix="/v1", tags=["v1"])
@router.post("/prezentari", response_model=PrezentariResponse)
def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
TODO(T3): validare Pydantic completa inainte de enqueue (VIN/data/nrInm),
ruteaza needs_data/needs_mapping.
TODO(auth): rezolva account_id din API key (acum None).
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
"""
account_id = None # TODO(auth): din API key
conn = get_connection()
results: list[SubmissionResult] = []
try:
for prez in req.prezentari:
content = prez.model_dump()
key = idempotency_key(account_id, content)
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
(key,),
).fetchone()
if existing:
results.append(
SubmissionResult(
submission_id=existing["id"],
status=existing["status"],
id_prezentare=existing["id_prezentare"],
deduped=True,
)
)
continue
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'queued', ?)",
(key, account_id, json.dumps(content, ensure_ascii=False)),
)
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status="queued"))
finally:
conn.close()
return PrezentariResponse(results=results)
@router.get("/prezentari")
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
conn = get_connection()
try:
if status:
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
(status, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
"FROM submissions ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return {"submissions": [dict(r) for r in rows]}
finally:
conn.close()
@router.get("/prezentari/{submission_id}")
def get_prezentare(submission_id: int) -> dict:
conn = get_connection()
try:
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="submission inexistent")
out = dict(row)
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
return out
finally:
conn.close()
@router.get("/nomenclator")
def get_nomenclator() -> dict:
conn = get_connection()
try:
rows = conn.execute(
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
).fetchall()
return {"nomenclator": [dict(r) for r in rows]}
finally:
conn.close()
@router.get("/mapari")
def get_mapari(account_id: int | None = None) -> dict:
conn = get_connection()
try:
if account_id is not None:
rows = conn.execute(
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
(account_id,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
return {"mapari": [dict(r) for r in rows]}
finally:
conn.close()

75
app/config.py Normal file
View File

@@ -0,0 +1,75 @@
"""Configurare gateway. Env vars (prefix AUTOPASS_) + valori implicite.
NU stocheaza parole RAR. Credentialele RAR vin per-cerere de la ROAAUTO
(vezi plan.md sect. 5). Helper-ul `load_test_credentials` citeste blocul
<test> din settings.xml DOAR pentru dev local / probe pe mediul de test.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AUTOPASS_", env_file=".env", extra="ignore")
# --- Bază de date ---
db_path: Path = ROOT / "data" / "autopass.db"
# --- RAR ---
rar_env: str = "test" # "test" | "prod"
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
rar_base_url_prod: str = "https://apps.rarom.ro/rar-autopass"
# WAF-ul RAR da 403 fara User-Agent de browser (confirmat live, vezi
# docs/api-rar-contract.md). Toate apelurile httpx il trimit.
http_user_agent: str = "Mozilla/5.0"
http_timeout_s: float = 30.0
# --- Worker ---
worker_poll_interval_s: float = 5.0
worker_heartbeat_stale_s: int = 30 # /healthz considera worker-ul mort peste atat
# In schelet send-ul e DEZACTIVAT (nu trimite la RAR). Activeaza-l explicit
# pentru proba end-to-end. Reconcilierea/retry-ul complet = T2.
worker_send_enabled: bool = False
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
# creds vin per-cerere de la ROAAUTO (T2) — lasa False.
worker_use_test_creds: bool = False
@property
def rar_base_url(self) -> str:
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
@lru_cache
def get_settings() -> Settings:
return Settings()
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
"""Citeste credentialele <test> din settings.xml (dev local / probe test).
Intoarce {"email", "password"} sau None daca fisierul lipseste / e template.
NU se foloseste in productie — acolo creds vin per-cerere de la ROAAUTO.
"""
path = settings_xml or (ROOT / "settings.xml")
if not path.exists():
return None
try:
root = ET.parse(path).getroot()
node = root.find("./test/credentials")
if node is None:
return None
email = (node.findtext("email") or "").strip()
password = (node.findtext("password") or "").strip()
if not email or not password or email.startswith("EMAIL_"):
return None
return {"email": email, "password": password}
except ET.ParseError:
return None

62
app/db.py Normal file
View File

@@ -0,0 +1,62 @@
"""Acces SQLite (WAL). Conexiune per-thread, schema idempotenta, heartbeat worker."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .config import get_settings
_SCHEMA = Path(__file__).resolve().parent / "schema.sql"
def _connect(db_path: Path) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path, timeout=15.0, isolation_level=None) # autocommit; tranzactii explicite
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 15000")
return conn
def get_connection() -> sqlite3.Connection:
"""Conexiune noua catre baza configurata. Apelantul o inchide."""
return _connect(get_settings().db_path)
def init_db() -> None:
"""Creeaza schema daca lipseste. Idempotent — sigur la fiecare boot."""
conn = get_connection()
try:
conn.executescript(_SCHEMA.read_text(encoding="utf-8"))
finally:
conn.close()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def write_heartbeat(conn: sqlite3.Connection, *, rar_login_ok: bool = False, detail: str = "") -> None:
"""Worker bate la fiecare iteratie. last_rar_login_ok se actualizeaza doar la login reusit."""
if rar_login_ok:
conn.execute(
"UPDATE worker_heartbeat SET last_beat=?, last_rar_login_ok=?, detail=? WHERE id=1",
(_now_iso(), _now_iso(), detail),
)
else:
conn.execute(
"UPDATE worker_heartbeat SET last_beat=?, detail=? WHERE id=1",
(_now_iso(), detail),
)
def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None:
return conn.execute("SELECT * FROM worker_heartbeat WHERE id=1").fetchone()
def queue_depth(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone()
return int(row["n"]) if row else 0

31
app/idempotency.py Normal file
View File

@@ -0,0 +1,31 @@
"""Cheie de idempotenta = hash de continut canonic.
RAR nu are camp nr. comanda si accepta duplicate -> dedup-ul e in sarcina noastra
(plan.md sect. 14). Hash stabil peste o reprezentare canonica a prezentarii.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
def idempotency_key(account_id: int | None, prezentare: dict[str, Any]) -> str:
"""SHA-256 peste (account_id + campurile semnificative ale prezentarii).
Exclude obs si b64Image (cosmetice, nu definesc unicitatea declaratiei).
"""
canonic = {
"account_id": account_id,
"vin": (prezentare.get("vin") or "").strip().upper(),
"nr_inmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
"data_prestatie": prezentare.get("data_prestatie"),
"odometru_final": str(prezentare.get("odometru_final") or "").strip(),
"prestatii": sorted(
str(p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", ""))
for p in (prezentare.get("prestatii") or [])
),
}
blob = json.dumps(canonic, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()

83
app/main.py Normal file
View File

@@ -0,0 +1,83 @@
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
Pornire dev: uvicorn app.main:app --reload
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from . import __version__
from .api.v1.router import router as api_v1_router
from .config import get_settings
from .db import get_connection, init_db, queue_depth, read_heartbeat
from .web.routes import router as web_router
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
app.include_router(api_v1_router)
app.include_router(web_router)
@app.get("/healthz")
def healthz() -> dict:
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
orchestratorul decide pe campul `worker_alive`.
"""
settings = get_settings()
conn = get_connection()
try:
hb = read_heartbeat(conn)
depth = queue_depth(conn)
finally:
conn.close()
worker_alive = False
last_beat = hb["last_beat"] if hb else None
if last_beat:
try:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last_beat)).total_seconds()
worker_alive = age <= settings.worker_heartbeat_stale_s
except ValueError:
worker_alive = False
return {
"ok": True,
"version": __version__,
"rar_env": settings.rar_env,
"worker_alive": worker_alive,
"last_beat": last_beat,
"last_rar_login_ok": hb["last_rar_login_ok"] if hb else None,
"queue_depth": depth,
}
@app.get("/metrics", response_class=PlainTextResponse)
def metrics() -> str:
"""Metrici text simplu (submissions pe status + backlog). Format Prometheus-lite."""
conn = get_connection()
try:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
finally:
conn.close()
lines = ["# submissions pe status"]
for r in rows:
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
return "\n".join(lines) + "\n"

54
app/models.py Normal file
View File

@@ -0,0 +1,54 @@
"""Modele Pydantic pentru suprafata API.
ATENTIE: validarea completa (regex VIN ^[A-HJ-NPR-Z0-9]{17}$, nrInmatriculare,
dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial
obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este
**T3** — aici sunt doar formele de baza. Vezi plan.md sect. 2 + roadmap T3.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class RarCredentials(BaseModel):
"""Credentiale RAR per-cerere (vin de la ROAAUTO din Oracle). NU se stocheaza."""
email: str
password: str
class PrestatieItem(BaseModel):
cod_prestatie: str = Field(..., description="cod din nomenclator RAR, ex. OE-1")
class PrezentareIn(BaseModel):
"""O prezentare de declarat la RAR (inainte de validarea T3)."""
vin: str
nr_inmatriculare: str
data_prestatie: str # YYYY-MM-DD; validare interval = T3
odometru_final: str # string per contract
odometru_initial: str | None = None
prestatii: list[PrestatieItem]
sistem_reparat: str = "null"
obs: str | None = None
b64_image: str | None = None
class PrezentareRequest(BaseModel):
"""Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR."""
rar_credentials: RarCredentials
prezentari: list[PrezentareIn] = Field(..., min_length=1)
class SubmissionResult(BaseModel):
submission_id: int
status: str
id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent
class PrezentariResponse(BaseModel):
results: list[SubmissionResult]

41
app/payload.py Normal file
View File

@@ -0,0 +1,41 @@
"""Constructor payload postPrezentare (schelet — T4 il completeaza).
Reguli din contract (docs/api-rar-contract.md):
- status mereu "FINALIZATA".
- tipPrestatie NU se trimite (server-generated GENERIC).
- odometruFinal ca string.
- sistemReparat trimis mereu (default "null").
- prestatii: [{codPrestatie, idPrezentare: null}].
- b64Image / odometruInitial optionale (se omit daca lipsesc).
T4 adauga snapshot-test fata de exemplul oficial din contract.
"""
from __future__ import annotations
from typing import Any
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
"""Mapeaza o prezentare interna -> payload exact pentru RAR postPrezentare."""
prestatii = prezentare.get("prestatii") or []
payload: dict[str, Any] = {
"vin": (prezentare.get("vin") or "").strip().upper(),
"nrInmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
"dataPrestatie": prezentare.get("data_prestatie"),
"odometruFinal": str(prezentare.get("odometru_final") or "").strip(),
"odometruInitial": prezentare.get("odometru_initial"),
"prestatii": [
{
"codPrestatie": (p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)),
"idPrezentare": None,
}
for p in prestatii
],
"sistemReparat": prezentare.get("sistem_reparat") or "null",
"status": "FINALIZATA",
}
if prezentare.get("obs"):
payload["obs"] = prezentare["obs"]
if prezentare.get("b64_image"):
payload["b64Image"] = prezentare["b64_image"]
return payload

130
app/rar_client.py Normal file
View File

@@ -0,0 +1,130 @@
"""Client RAR AUTOPASS — portare din rar_autopass.prg / rar-forms.prg.
Sursa de adevar pentru contract: docs/api-rar-contract.md (verificat live 2026-06-15).
Reguli care guverneaza acest client:
- TOATE apelurile trimit header User-Agent (altfel WAF da 403).
- login -> JWT (TTL 30h); token-ul se ataseaza ca `Authorization: Bearer`.
- postPrezentare: status mereu "FINALIZATA"; NU se trimite tipPrestatie.
- nomenclator: GET /nomenclator/getNomenclatorPrestatii (NU getPrestatiiNom -> 403).
- eroare validare RAR: HTTP 400, data = listă [{field, message}] (NU data.message).
"""
from __future__ import annotations
from typing import Any
import httpx
from .config import Settings, get_settings
class RarError(Exception):
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400."""
def __init__(self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None):
super().__init__(message)
self.status_code = status_code
self.field_errors = field_errors or []
class RarAuthError(RarError):
"""Login esuat (401 / credentiale invalide). NU se face retry."""
class RarClient:
"""Client sincron httpx. Folosit din worker (proces separat).
Utilizare:
with RarClient() as rar:
token = rar.login(email, password)
data = rar.post_prezentare(token, payload)
"""
def __init__(self, settings: Settings | None = None):
self.settings = settings or get_settings()
self._client = httpx.Client(
base_url=self.settings.rar_base_url,
timeout=self.settings.http_timeout_s,
headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403
)
def __enter__(self) -> "RarClient":
return self
def __exit__(self, *exc: object) -> None:
self.close()
def close(self) -> None:
self._client.close()
# --- Autentificare ---
def login(self, email: str, password: str) -> str:
"""POST /public/login -> JWT (str). Ridica RarAuthError la 401."""
resp = self._client.post("/public/login", json={"email": email, "password": password})
if resp.status_code == 401:
raise RarAuthError("Credentiale RAR invalide", status_code=401)
if resp.status_code != 200:
raise RarError(f"Login esuat (HTTP {resp.status_code})", status_code=resp.status_code)
token = resp.json().get("token")
if not token:
raise RarError("Login fara token in raspuns", status_code=resp.status_code)
return token
# --- Nomenclator ---
def get_nomenclator(self, token: str) -> list[dict]:
"""GET /nomenclator/getNomenclatorPrestatii -> listă coduri prestatii."""
resp = self._client.get(
"/nomenclator/getNomenclatorPrestatii",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
raise RarError(f"Nomenclator esuat (HTTP {resp.status_code})", status_code=resp.status_code)
data = resp.json()
# Raspunsul poate fi listă directa sau {data: [...]}; normalizam.
return data.get("data", data) if isinstance(data, dict) else data
# --- Prezentari ---
def post_prezentare(self, token: str, payload: dict[str, Any]) -> dict:
"""POST /prezentari/postPrezentare. Intoarce `data` (obiect) la succes.
La 400 (validare) ridica RarError cu field_errors din `data` (listă).
Apelantul NU trebuie sa includa tipPrestatie; status trebuie "FINALIZATA".
"""
resp = self._client.post(
"/prezentari/postPrezentare",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
body = _safe_json(resp)
if resp.status_code == 200:
return body.get("data", {}) if isinstance(body, dict) else {}
if resp.status_code == 400 and isinstance(body, dict):
errors = body.get("data") if isinstance(body.get("data"), list) else []
msg = body.get("message", "Validare esuata la RAR")
raise RarError(msg, status_code=400, field_errors=errors)
raise RarError(f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code)
def get_finalizate(self, token: str) -> list[dict]:
"""Lista prezentarilor finalizate (pentru reconciliere — T2).
Atentie: pe mediul TEST raspunsul NU contine `prestatii` (vezi contract).
Portare din rar-forms.prg:720 / getAllPrezentariFinalizate.
"""
resp = self._client.get(
"/prezentari/getAllPrezentariFinalizate",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
raise RarError(f"getFinalizate esuat (HTTP {resp.status_code})", status_code=resp.status_code)
data = _safe_json(resp)
return data.get("data", data) if isinstance(data, dict) else data
def _safe_json(resp: httpx.Response) -> Any:
try:
return resp.json()
except ValueError:
return {"message": resp.text}

73
app/schema.sql Normal file
View File

@@ -0,0 +1,73 @@
-- Schema SQLite (WAL) pentru gateway RAR AUTOPASS.
-- Vezi plan.md sect. 5. NICIUN camp pentru parole RAR.
-- Validarea completa (T3) si criptarea PII (P2) vin ulterior; in schelet
-- payload-ul e stocat ca JSON text (camp payload_json), de inlocuit cu BLOB
-- criptat + purge_after cand se face T7/criptare.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Conturi ROAAUTO (clientii care folosesc gateway-ul).
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cui TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Chei API per cont (separate de creds RAR). Stocam doar hash-ul.
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
-- Mapare operatie service -> codPrestatie RAR (← mapare_prestatii.DBF, T5).
CREATE TABLE IF NOT EXISTS operations_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
cod_op_service TEXT NOT NULL,
cod_prestatie TEXT NOT NULL,
auto_send INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (account_id, cod_op_service)
);
-- Cache nomenclator RAR {codPrestatie, numePrestatie} (← prestatii_rar.DBF / live).
CREATE TABLE IF NOT EXISTS nomenclator_rar (
cod_prestatie TEXT PRIMARY KEY,
nume_prestatie TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Coada de prezentari catre RAR. Masina de stari: plan.md sect. 3.
CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT NOT NULL UNIQUE,
account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
payload_json TEXT NOT NULL, -- TODO(P2): inlocuit cu BLOB criptat
rar_status_code INTEGER,
rar_error TEXT,
id_prezentare INTEGER, -- data.id intors de RAR la succes
retry_count INTEGER NOT NULL DEFAULT 0,
sending_since TEXT, -- pentru lease/timeout pe randuri 'sending' orfane (T2)
purge_after TEXT, -- sent + 90z (P2)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_beat TEXT,
last_rar_login_ok TEXT,
detail TEXT
);
INSERT OR IGNORE INTO worker_heartbeat (id, last_beat, detail) VALUES (1, NULL, 'never started');

0
app/web/__init__.py Normal file
View File

85
app/web/routes.py Normal file
View File

@@ -0,0 +1,85 @@
"""Dashboard Jinja2 + HTMX (server-rendered, zero build).
Schelet cu stari explicite: empty (coada goala), banner alerta blocate,
worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator +
export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review).
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..config import get_settings
from ..db import get_connection, read_heartbeat
router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
_BLOCKED = ("error", "needs_data", "needs_mapping")
def _status_counts(conn) -> dict[str, int]:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
return {r["status"]: int(r["n"]) for r in rows}
def _worker_alive(hb) -> bool:
if hb is None or not hb["last_beat"]:
return False
try:
last = datetime.fromisoformat(hb["last_beat"])
except ValueError:
return False
age = (datetime.now(timezone.utc) - last).total_seconds()
return age <= get_settings().worker_heartbeat_stale_s
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse:
conn = get_connection()
try:
counts = _status_counts(conn)
hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
ctx = {
"request": request,
"rar_env": get_settings().rar_env,
"version": __version__,
"counts": counts,
"blocked": blocked,
"worker_alive": _worker_alive(hb),
"last_login": hb["last_rar_login_ok"] if hb else None,
}
return templates.TemplateResponse("dashboard.html", ctx)
finally:
conn.close()
@router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse:
conn = get_connection()
try:
counts = _status_counts(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked})
finally:
conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse:
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
"FROM submissions ORDER BY id DESC LIMIT 100"
).fetchall()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally:
conn.close()

View File

@@ -0,0 +1,5 @@
<div class="card banner {% if not blocked %}hidden{% endif %}"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div>

View File

@@ -0,0 +1,20 @@
{% if rows %}
<table>
<thead><tr><th>#</th><th>Stare</th><th>idPrezentare</th><th>HTTP RAR</th><th>Retry</th><th>Actualizat</th><th>Motiv</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
<td><span class="pill s-{{ r.status }}">{{ r.status }}</span></td>
<td>{{ r.id_prezentare or '—' }}</td>
<td>{{ r.rar_status_code or '—' }}</td>
<td>{{ r.retry_count }}</td>
<td class="muted">{{ r.updated_at }}</td>
<td class="muted">{{ (r.rar_error or '')[:80] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">Coada e goala. Trimite o prezentare prin <code>POST /v1/prezentari</code>.</div>
{% endif %}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Gateway RAR AUTOPASS{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
* { box-sizing:border-box; }
body { margin:0; font:15px/1.5 system-ui,sans-serif; background:var(--bg); color:var(--ink); }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
header h1 { font-size:16px; margin:0; font-weight:600; }
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
main { padding:24px; max-width:1100px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin-bottom:16px; }
.banner { border-left:3px solid var(--err); background:#241a1a; }
.banner.hidden { display:none; }
table { width:100%; border-collapse:collapse; font-size:14px; }
th,td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); }
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.empty { color:var(--muted); padding:24px; text-align:center; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
.s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);}
.s-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.muted { color:var(--muted); }
a { color:var(--accent); }
</style>
</head>
<body>
<header>
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
<span class="muted" style="margin-left:auto; font-size:13px;">v{{ version }}</span>
</header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="card banner {% if not blocked %}hidden{% endif %}"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div>
<div class="card">
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div><div class="muted">Worker</div><div class="{{ 's-sent' if worker_alive else 's-error' }}">
{{ 'viu' if worker_alive else 'mort' }}</div></div>
<div><div class="muted">Ultimul login RAR</div><div>{{ last_login or '—' }}</div></div>
<div><div class="muted">In coada</div><div>{{ counts.get('queued', 0) }}</div></div>
<div><div class="muted">Trimise</div><div class="s-sent">{{ counts.get('sent', 0) }}</div></div>
<div><div class="muted">Blocate</div><div class="{{ 's-error' if blocked else '' }}">{{ blocked }}</div></div>
</div>
</div>
<div class="card">
<h2 style="font-size:14px; margin:0 0 12px;">Coada submissions</h2>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
{% endblock %}

0
app/worker/__init__.py Normal file
View File

156
app/worker/__main__.py Normal file
View File

@@ -0,0 +1,156 @@
"""Worker RAR — proces propriu (NU task asyncio in uvicorn; plan.md sect. 4).
Bucla: heartbeat -> claim atomic (BEGIN IMMEDIATE) -> login -> postPrezentare -> update.
Ruleaza ca proces separat sub `restart: always` (docker compose).
Schelet — ce E implementat: heartbeat, claim atomic anti-race, login cu token
cache, postPrezentare cu maparea erorilor de validare (400 -> needs_data).
Ce NU e inca (marcat TODO): reconcilierea anti-duplicat pe raspuns pierdut (T2),
retry/backoff exponential (T2), lease/timeout pe randuri 'sending' orfane (T2),
livrarea creds per-cerere de la ROAAUTO (T2 — in schelet folosim creds <test> local).
Pornire: python -m app.worker
"""
from __future__ import annotations
import json
import signal
import sys
import time
from ..config import get_settings, load_test_credentials
from ..db import get_connection, init_db, write_heartbeat
from ..payload import build_rar_payload
from ..rar_client import RarAuthError, RarClient, RarError
_running = True
def _stop(signum: int, frame: object) -> None:
global _running
_running = False
def claim_one(conn) -> dict | None:
"""Claim atomic al unui rand 'queued' -> 'sending'. Intoarce randul sau None.
BEGIN IMMEDIATE ia lock de scriere imediat, deci doi workeri nu pot lua
acelasi rand. (Un singur worker in v1, dar claim-ul ramane corect la scalare.)
"""
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute(
"SELECT id, payload_json FROM submissions WHERE status='queued' ORDER BY id LIMIT 1"
).fetchone()
if not row:
conn.execute("COMMIT")
return None
conn.execute(
"UPDATE submissions SET status='sending', sending_since=datetime('now'), "
"updated_at=datetime('now') WHERE id=?",
(row["id"],),
)
conn.execute("COMMIT")
return {"id": row["id"], "content": json.loads(row["payload_json"])}
except Exception:
conn.execute("ROLLBACK")
raise
def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
conn.execute(
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
"updated_at=datetime('now') WHERE id=?",
(status, rar_status_code, rar_error, id_prezentare, submission_id),
)
def process_one(conn, rar: RarClient, token: str, claimed: dict) -> None:
"""Trimite o prezentare claimed. Mapeaza rezultatul pe masina de stari.
TODO(T2): inainte de re-send pe un rand ramas 'sending' (raspuns pierdut),
interogheaza get_finalizate pe VIN+dataPrestatie+odometruFinal si marcheaza
'sent' daca exista deja (anti-duplicat). UNIQUE NU acopera acest caz.
"""
sid = claimed["id"]
payload = build_rar_payload(claimed["content"])
try:
data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id"))
print(f"[worker] submission {sid} -> sent (idPrezentare={data.get('id')})", flush=True)
except RarError as exc:
if exc.status_code == 400:
# Validare esuata la RAR -> needs_data (nu re-trimite orb).
detail = json.dumps(exc.field_errors, ensure_ascii=False) if exc.field_errors else str(exc)
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail)
print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True)
else:
# TODO(T2): retry/backoff in loc de error direct pe 5xx/tranzitoriu.
mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc))
print(f"[worker] submission {sid} -> error: {exc}", flush=True)
def run() -> int:
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
settings = get_settings()
init_db()
conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
creds = load_test_credentials() if settings.worker_use_test_creds else None
rar: RarClient | None = None
token: str | None = None
while _running:
try:
depth_detail = f"poll (queue={_queue_depth(conn)})"
write_heartbeat(conn, detail=depth_detail)
if not settings.worker_send_enabled:
time.sleep(settings.worker_poll_interval_s)
continue
claimed = claim_one(conn)
if claimed is None:
time.sleep(settings.worker_poll_interval_s)
continue
if not creds:
# TODO(T2): canalul real de creds per-cerere de la ROAAUTO.
mark(conn, claimed["id"], "error", rar_error="creds RAR indisponibile (T2)")
continue
# Login lazy + token cache (JWT 30h). Re-login la expirare = T2.
if rar is None or token is None:
rar = RarClient(settings)
token = rar.login(creds["email"], creds["password"])
write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok")
process_one(conn, rar, token, claimed)
except RarAuthError as exc:
print(f"[worker] login esuat: {exc}", flush=True)
token = None # forteaza re-login data viitoare
time.sleep(settings.worker_poll_interval_s)
except Exception as exc: # noqa: BLE001 — loop top-level, nu cadem la o eroare punctuala
print(f"[worker] eroare neasteptata: {exc}", flush=True)
time.sleep(settings.worker_poll_interval_s)
if rar is not None:
rar.close()
conn.close()
print("[worker] oprit curat", flush=True)
return 0
def _queue_depth(conn) -> int:
row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone()
return int(row["n"]) if row else 0
if __name__ == "__main__":
sys.exit(run())

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
# Gateway RAR AUTOPASS — un container API + un container worker, acelasi image,
# acelasi volum SQLite persistent (plan.md sect. 4 + 9). restart: always pe ambele.
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
volumes:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: test
restart: always
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
worker:
build: .
command: python -m app.worker
volumes:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: test
# Send dezactivat by default; activeaza pentru proba end-to-end.
AUTOPASS_WORKER_SEND_ENABLED: "false"
restart: always
depends_on:
- api
volumes:
autopass-data:

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
# Gateway RAR AUTOPASS — dependinte runtime
# Versiuni aliniate la ce e instalat in container (2026-06-15).
fastapi==0.115.0
uvicorn[standard]==0.30.0
httpx==0.27.*
jinja2==3.1.*
pydantic==2.8.2
pydantic-settings==2.*
python-multipart==0.0.*
# Migrare DBF (tools/import_dbf.py — T5). Necesar doar pentru import, nu pentru runtime.
dbfread==2.0.7

0
tools/__init__.py Normal file
View File

37
tools/import_dbf.py Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Import DBF -> SQLite (T5 — SCHELET, neimplementat inca).
Plan.md sect. 7: dry-run + raport intai (randuri valide, mapari orfane, coduri
necunoscute in nomenclator), apoi scrie in SQLite. Surse:
- mapare_prestatii.DBF -> operations_mapping
- prestatii_rar.DBF -> nomenclator_rar
(rar_log.DBF NU se migreaza.)
Utilizare (cand e implementat):
python -m tools.import_dbf --dry-run
python -m tools.import_dbf --commit
Necesita: pip install dbfread
"""
from __future__ import annotations
import argparse
import sys
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import DBF ROAAUTO -> SQLite gateway (T5)")
parser.add_argument("--dry-run", action="store_true", help="raport fara scriere (default)")
parser.add_argument("--commit", action="store_true", help="scrie in SQLite dupa confirmare")
parser.parse_args(argv)
print("tools/import_dbf.py este SCHELET (T5). De implementat:")
print(" 1. citeste mapare_prestatii.DBF + prestatii_rar.DBF cu dbfread")
print(" 2. raport: randuri valide, mapari orfane, coduri necunoscute in nomenclator")
print(" 3. la --commit: INSERT idempotent in operations_mapping / nomenclator_rar")
return 1
if __name__ == "__main__":
sys.exit(main())