""" 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()