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:
21
.dockerignore
Normal file
21
.dockerignore
Normal 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
23
Dockerfile
Normal 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
7
app/__init__.py
Normal 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
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
127
app/api/v1/router.py
Normal file
127
app/api/v1/router.py
Normal 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
75
app/config.py
Normal 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
62
app/db.py
Normal 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
31
app/idempotency.py
Normal 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
83
app/main.py
Normal 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
54
app/models.py
Normal 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
41
app/payload.py
Normal 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
130
app/rar_client.py
Normal 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
73
app/schema.sql
Normal 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
0
app/web/__init__.py
Normal file
85
app/web/routes.py
Normal file
85
app/web/routes.py
Normal 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()
|
||||||
5
app/web/templates/_banner.html
Normal file
5
app/web/templates/_banner.html
Normal 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 > 30h. Verifica coada mai jos.
|
||||||
|
</div>
|
||||||
20
app/web/templates/_submissions.html
Normal file
20
app/web/templates/_submissions.html
Normal 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 %}
|
||||||
39
app/web/templates/base.html
Normal file
39
app/web/templates/base.html
Normal 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>
|
||||||
28
app/web/templates/dashboard.html
Normal file
28
app/web/templates/dashboard.html
Normal 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 > 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
0
app/worker/__init__.py
Normal file
156
app/worker/__main__.py
Normal file
156
app/worker/__main__.py
Normal 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
36
docker-compose.yml
Normal 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
12
requirements.txt
Normal 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
0
tools/__init__.py
Normal file
37
tools/import_dbf.py
Normal file
37
tools/import_dbf.py
Normal 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())
|
||||||
Reference in New Issue
Block a user