diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..535555b --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6e933e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..c258709 --- /dev/null +++ b/app/__init__.py @@ -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" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..485018c --- /dev/null +++ b/app/api/v1/router.py @@ -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() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9772be4 --- /dev/null +++ b/app/config.py @@ -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 + 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 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 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 diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..6fedf8b --- /dev/null +++ b/app/db.py @@ -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 diff --git a/app/idempotency.py b/app/idempotency.py new file mode 100644 index 0000000..7cf6c6f --- /dev/null +++ b/app/idempotency.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..9b7e654 --- /dev/null +++ b/app/main.py @@ -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" diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..80f6e0b --- /dev/null +++ b/app/models.py @@ -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] diff --git a/app/payload.py b/app/payload.py new file mode 100644 index 0000000..ffff859 --- /dev/null +++ b/app/payload.py @@ -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 diff --git a/app/rar_client.py b/app/rar_client.py new file mode 100644 index 0000000..0c5fdd9 --- /dev/null +++ b/app/rar_client.py @@ -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} diff --git a/app/schema.sql b/app/schema.sql new file mode 100644 index 0000000..61e379c --- /dev/null +++ b/app/schema.sql @@ -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'); diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/routes.py b/app/web/routes.py new file mode 100644 index 0000000..19f1d3a --- /dev/null +++ b/app/web/routes.py @@ -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() diff --git a/app/web/templates/_banner.html b/app/web/templates/_banner.html new file mode 100644 index 0000000..72498ad --- /dev/null +++ b/app/web/templates/_banner.html @@ -0,0 +1,5 @@ + diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html new file mode 100644 index 0000000..648b49e --- /dev/null +++ b/app/web/templates/_submissions.html @@ -0,0 +1,20 @@ +{% if rows %} + + + + {% for r in rows %} + + + + + + + + + + {% endfor %} + +
#StareidPrezentareHTTP RARRetryActualizatMotiv
{{ r.id }}{{ r.status }}{{ r.id_prezentare or '—' }}{{ r.rar_status_code or '—' }}{{ r.retry_count }}{{ r.updated_at }}{{ (r.rar_error or '')[:80] }}
+{% else %} +
Coada e goala. Trimite o prezentare prin POST /v1/prezentari.
+{% endif %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html new file mode 100644 index 0000000..103bdeb --- /dev/null +++ b/app/web/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}Gateway RAR AUTOPASS{% endblock %} + + + + +
+

Gateway RAR AUTOPASS

+ {{ rar_env }} + v{{ version }} +
+
{% block content %}{% endblock %}
+ + diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html new file mode 100644 index 0000000..8e573a4 --- /dev/null +++ b/app/web/templates/dashboard.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
Worker
+ {{ 'viu' if worker_alive else 'mort' }}
+
Ultimul login RAR
{{ last_login or '—' }}
+
In coada
{{ counts.get('queued', 0) }}
+
Trimise
{{ counts.get('sent', 0) }}
+
Blocate
{{ blocked }}
+
+
+ +
+

Coada submissions

+
+
se incarca…
+
+
+ +{% endblock %} diff --git a/app/worker/__init__.py b/app/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/__main__.py b/app/worker/__main__.py new file mode 100644 index 0000000..aee87b3 --- /dev/null +++ b/app/worker/__main__.py @@ -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 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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b25fe5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba6c509 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/import_dbf.py b/tools/import_dbf.py new file mode 100644 index 0000000..5da35c1 --- /dev/null +++ b/tools/import_dbf.py @@ -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())