mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Versamento su API AgID
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Modulo archiviazione sostitutiva (Fase 6)
|
||||
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Client conservatore AgID – supporta modalità mock e produzione.
|
||||
|
||||
Modalità mock (default in sviluppo):
|
||||
- Simula localmente tutte le chiamate al conservatore
|
||||
- Restituisce risposte sintetiche plausibili (RdV false)
|
||||
- Non effettua alcuna chiamata di rete esterna
|
||||
- Utile per sviluppo, test e demo
|
||||
|
||||
Modalità produzione:
|
||||
- Esegue chiamate HTTP reali all'endpoint AgID del conservatore configurato
|
||||
- Usa le credenziali cifrate recuperate dalle impostazioni del tenant
|
||||
- Autenticazione HTTP Basic (standard AgID per versamenti SIP)
|
||||
|
||||
Come switchare da mock a produzione:
|
||||
L'admin del tenant configura la modalità dalla pagina Impostazioni del
|
||||
frontend → sezione "Archiviazione Sostitutiva".
|
||||
Le credenziali vengono salvate cifrate nel DB (AES-256-GCM, ADR-002).
|
||||
Il worker legge la configurazione a runtime dalla tabella tenant_settings.
|
||||
|
||||
Interfaccia pubblica (stessa per entrambe le modalità):
|
||||
client = ConservatoreClient.from_tenant_credentials(creds)
|
||||
result = await client.upload_versamento(sip_path, sip_bytes)
|
||||
status = await client.get_versamento_status(versamento_id)
|
||||
dip = await client.get_dip(versamento_id)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings # noqa: F401 (futuro uso logger)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ─── DTO risultati ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class VersamentoResult:
|
||||
"""Risultato dell'upload di un pacchetto SIP al conservatore."""
|
||||
success: bool
|
||||
versamento_id: str | None = None
|
||||
message: str = ""
|
||||
raw_response: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersamentoStatus:
|
||||
"""Stato di un versamento in corso o completato."""
|
||||
versamento_id: str
|
||||
status: str # pending | processing | accepted | rejected
|
||||
message: str = ""
|
||||
rdv_available: bool = False
|
||||
raw_response: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DipResult:
|
||||
"""Risultato di una richiesta DIP (Dissemination Information Package)."""
|
||||
success: bool
|
||||
dip_id: str | None = None
|
||||
download_url: str | None = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
# ─── Client base (interfaccia) ────────────────────────────────────────────────
|
||||
|
||||
class _BaseConservatoreClient:
|
||||
"""Interfaccia comune per mock e produzione."""
|
||||
|
||||
async def upload_versamento(
|
||||
self,
|
||||
sip_path: str,
|
||||
sip_bytes: bytes,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> VersamentoResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_versamento_status(
|
||||
self, versamento_id: str
|
||||
) -> VersamentoStatus:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ─── Implementazione MOCK ─────────────────────────────────────────────────────
|
||||
|
||||
class MockConservatoreClient(_BaseConservatoreClient):
|
||||
"""
|
||||
Conservatore simulato – nessuna chiamata di rete.
|
||||
|
||||
Simula tempi realistici e risponde con dati fittizi ma strutturalmente
|
||||
corretti, in modo che il resto del codice possa essere testato end-to-end
|
||||
senza dipendenze esterne.
|
||||
"""
|
||||
|
||||
async def upload_versamento(
|
||||
self,
|
||||
sip_path: str,
|
||||
sip_bytes: bytes,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> VersamentoResult:
|
||||
"""Simula upload SIP: genera un versamento_id deterministico."""
|
||||
checksum = hashlib.sha256(sip_bytes).hexdigest()[:16]
|
||||
tenant_prefix = str(tenant_id)[:8]
|
||||
versamento_id = f"MOCK-{tenant_prefix}-{checksum}"
|
||||
|
||||
return VersamentoResult(
|
||||
success=True,
|
||||
versamento_id=versamento_id,
|
||||
message="[MOCK] Versamento accettato dal conservatore simulato",
|
||||
raw_response={
|
||||
"versamento_id": versamento_id,
|
||||
"stato": "accepted",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"mock": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_versamento_status(
|
||||
self, versamento_id: str
|
||||
) -> VersamentoStatus:
|
||||
"""Simula polling stato: risponde sempre 'accepted' con RdV disponibile."""
|
||||
return VersamentoStatus(
|
||||
versamento_id=versamento_id,
|
||||
status="accepted",
|
||||
message="[MOCK] Versamento confermato",
|
||||
rdv_available=True,
|
||||
raw_response={
|
||||
"versamento_id": versamento_id,
|
||||
"stato": "accepted",
|
||||
"rdv_disponibile": True,
|
||||
"mock": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||
"""Simula richiesta DIP."""
|
||||
dip_id = f"DIP-{versamento_id}"
|
||||
return DipResult(
|
||||
success=True,
|
||||
dip_id=dip_id,
|
||||
download_url=f"http://mock-conservatore.local/dip/{dip_id}",
|
||||
message="[MOCK] DIP disponibile",
|
||||
)
|
||||
|
||||
|
||||
# ─── Implementazione PRODUZIONE ───────────────────────────────────────────────
|
||||
|
||||
class ProductionConservatoreClient(_BaseConservatoreClient):
|
||||
"""
|
||||
Client HTTP reale per conservatore AgID.
|
||||
|
||||
Autenticazione: HTTP Basic (standard AgID CNIPA).
|
||||
Formato SIP: UNI SInCRO 11386:2023 (pacchetto ZIP con indice XML).
|
||||
|
||||
L'URL endpoint e le credenziali arrivano dalla tabella tenant_settings
|
||||
(decifrate a runtime dal TenantSettingsService).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
username: str,
|
||||
password: str,
|
||||
conservatore_id: str = "production",
|
||||
timeout_seconds: int = 120,
|
||||
) -> None:
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.conservatore_id = conservatore_id
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def upload_versamento(
|
||||
self,
|
||||
sip_path: str,
|
||||
sip_bytes: bytes,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> VersamentoResult:
|
||||
"""
|
||||
POST {endpoint}/versamento
|
||||
Content-Type: application/octet-stream (o multipart/form-data per alcuni provider)
|
||||
Authorization: Basic base64(user:pass)
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return VersamentoResult(
|
||||
success=False,
|
||||
message="httpx non installato nel worker. Aggiungere alla dipendenza.",
|
||||
)
|
||||
|
||||
import base64
|
||||
auth_str = base64.b64encode(
|
||||
f"{self.username}:{self.password}".encode()
|
||||
).decode()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
||||
response = await client.post(
|
||||
f"{self.endpoint}/versamento",
|
||||
content=sip_bytes,
|
||||
headers={
|
||||
"Authorization": f"Basic {auth_str}",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Tenant-ID": str(tenant_id),
|
||||
"X-SIP-Path": sip_path,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201, 202):
|
||||
data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
||||
_fallback_id = str(uuid.uuid4())[:8]
|
||||
versamento_id = data.get("versamento_id") or data.get("id") or f"VERS-{_fallback_id}"
|
||||
return VersamentoResult(
|
||||
success=True,
|
||||
versamento_id=str(versamento_id),
|
||||
message="Versamento accettato dal conservatore",
|
||||
raw_response=data,
|
||||
)
|
||||
else:
|
||||
return VersamentoResult(
|
||||
success=False,
|
||||
message=f"Conservatore ha risposto con errore HTTP {response.status_code}: {response.text[:500]}",
|
||||
raw_response={"status_code": response.status_code, "body": response.text[:500]},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return VersamentoResult(
|
||||
success=False,
|
||||
message=f"Errore di connessione al conservatore: {e}",
|
||||
)
|
||||
|
||||
async def get_versamento_status(
|
||||
self, versamento_id: str
|
||||
) -> VersamentoStatus:
|
||||
"""GET {endpoint}/versamento/{versamento_id}"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return VersamentoStatus(
|
||||
versamento_id=versamento_id,
|
||||
status="unknown",
|
||||
message="httpx non installato",
|
||||
)
|
||||
|
||||
import base64
|
||||
auth_str = base64.b64encode(
|
||||
f"{self.username}:{self.password}".encode()
|
||||
).decode()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
f"{self.endpoint}/versamento/{versamento_id}",
|
||||
headers={"Authorization": f"Basic {auth_str}"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
stato = data.get("stato", data.get("status", "unknown"))
|
||||
return VersamentoStatus(
|
||||
versamento_id=versamento_id,
|
||||
status=str(stato),
|
||||
message=data.get("message", ""),
|
||||
rdv_available=data.get("rdv_disponibile", False),
|
||||
raw_response=data,
|
||||
)
|
||||
else:
|
||||
return VersamentoStatus(
|
||||
versamento_id=versamento_id,
|
||||
status="error",
|
||||
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return VersamentoStatus(
|
||||
versamento_id=versamento_id,
|
||||
status="error",
|
||||
message=f"Errore connessione: {e}",
|
||||
)
|
||||
|
||||
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||
"""POST {endpoint}/dip con il versamento_id"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return DipResult(success=False, message="httpx non installato")
|
||||
|
||||
import base64
|
||||
auth_str = base64.b64encode(
|
||||
f"{self.username}:{self.password}".encode()
|
||||
).decode()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
f"{self.endpoint}/dip",
|
||||
json={"versamento_id": versamento_id},
|
||||
headers={
|
||||
"Authorization": f"Basic {auth_str}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201, 202):
|
||||
data = response.json()
|
||||
return DipResult(
|
||||
success=True,
|
||||
dip_id=str(data.get("dip_id", "")),
|
||||
download_url=data.get("download_url"),
|
||||
message="DIP disponibile",
|
||||
)
|
||||
else:
|
||||
return DipResult(
|
||||
success=False,
|
||||
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DipResult(success=False, message=f"Errore connessione: {e}")
|
||||
|
||||
|
||||
# ─── Factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ConservatoreClient:
|
||||
"""
|
||||
Factory che istanzia il client corretto in base alla modalità.
|
||||
|
||||
Utilizzo dal worker:
|
||||
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
|
||||
client = ConservatoreClient.from_tenant_credentials(creds)
|
||||
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_tenant_credentials(creds: dict) -> _BaseConservatoreClient:
|
||||
"""
|
||||
Crea il client appropriato dalla configurazione tenant.
|
||||
|
||||
Args:
|
||||
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
|
||||
con chiavi: mode, conservatore_id, endpoint, username, password
|
||||
"""
|
||||
mode = creds.get("mode", "mock")
|
||||
|
||||
if mode == "production":
|
||||
endpoint = creds.get("endpoint")
|
||||
username = creds.get("username")
|
||||
password = creds.get("password")
|
||||
|
||||
if not endpoint:
|
||||
raise ValueError(
|
||||
"Modalità produzione attiva ma conservatore_endpoint non configurato. "
|
||||
"Verificare le impostazioni del tenant."
|
||||
)
|
||||
if not username or not password:
|
||||
raise ValueError(
|
||||
"Modalità produzione attiva ma credenziali conservatore mancanti. "
|
||||
"Configurare username e password nelle impostazioni del tenant."
|
||||
)
|
||||
|
||||
return ProductionConservatoreClient(
|
||||
endpoint=endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
conservatore_id=creds.get("conservatore_id", "production"),
|
||||
)
|
||||
|
||||
# Default: modalità mock (sicura per sviluppo)
|
||||
return MockConservatoreClient()
|
||||
Reference in New Issue
Block a user