""" 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: - Supporta Aeterna (api.aeterna.idrainformatica.it) con autenticazione JWT - Costruisce pacchetti SIP in formato BagIt RFC 8493 (auto-rilevato da Aeterna) - Upload multipart/form-data a POST /api/v1/ingest/upload - Polling status via GET /api/v1/ingest/{package_id}/status - DIP via POST /api/v1/packages/{package_id}/disseminate 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, tenant_id) status = await client.get_versamento_status(versamento_id) dip = await client.get_dip(versamento_id) """ from __future__ import annotations import hashlib import io import time import uuid import zipfile 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", ) # ─── BagIt SIP Builder ──────────────────────────────────────────────────────── def build_bagit_sip( eml_bytes: bytes, message_id: str, subject: str | None = None, from_address: str | None = None, to_addresses: list[str] | None = None, received_at: str | None = None, ) -> bytes: """ Costruisce un pacchetto SIP in formato BagIt RFC 8493 come ZIP in memoria. La struttura generata è: {bag_name}/ bagit.txt BagIt-Version + Tag-File-Character-Encoding bag-info.txt Metadati descrittivi del messaggio PEC manifest-sha256.txt Checksum SHA-256 del file EML data/ {message_id}.eml Il messaggio PEC grezzo BagIt è accettato da Aeterna e rilevato automaticamente dalla pipeline di ingest (presenza di bagit.txt nella root del bag). Args: eml_bytes: Contenuto grezzo del file .eml message_id: UUID del messaggio PecHub (usato come nome file) subject: Oggetto del messaggio (per bag-info.txt) from_address: Mittente to_addresses: Destinatari received_at: Data di ricezione ISO 8601 Returns: Bytes dello ZIP del pacchetto BagIt """ bag_name = f"pechub-pec-{message_id}" eml_filename = f"{message_id}.eml" data_path = f"data/{eml_filename}" eml_sha256 = hashlib.sha256(eml_bytes).hexdigest() # Contenuto bagit.txt (obbligatorio per BagIt RFC 8493) bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n" # Contenuto bag-info.txt (metadati descrittivi, opzionale ma utile) bag_info_lines = [ f"Bag-Software-Agent: PecHub Archival Module", f"Bagging-Date: {datetime.now(UTC).strftime('%Y-%m-%d')}", f"External-Identifier: {message_id}", f"Source-Organization: PecHub", ] if subject: bag_info_lines.append(f"Description: {subject[:500]}") if from_address: bag_info_lines.append(f"Contact-Email: {from_address}") if to_addresses: bag_info_lines.append(f"External-Description: PEC a {', '.join(to_addresses[:3])}") if received_at: bag_info_lines.append(f"Bag-Group-Identifier: {received_at[:10]}") bag_info_txt = "\n".join(bag_info_lines) + "\n" # Contenuto manifest-sha256.txt (checksum file nella directory data/) manifest_txt = f"{eml_sha256} {data_path}\n" # Costruzione ZIP in memoria buf = io.BytesIO() with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"{bag_name}/bagit.txt", bagit_txt) zf.writestr(f"{bag_name}/bag-info.txt", bag_info_txt) zf.writestr(f"{bag_name}/manifest-sha256.txt", manifest_txt) zf.writestr(f"{bag_name}/{data_path}", eml_bytes) return buf.getvalue() def build_bagit_sip_complete( eml_bytes: bytes, message_id: str, subject: str | None = None, from_address: str | None = None, to_addresses: list[str] | None = None, received_at: str | None = None, attachments: list[tuple[str, bytes]] | None = None, receipts: list[tuple[str, bytes]] | None = None, ) -> bytes: """ Costruisce un pacchetto SIP BagIt completo con EML principale, allegati e ricevute. Struttura generata: {bag_name}/ bagit.txt bag-info.txt manifest-sha256.txt data/ {message_id}.eml EML messaggio originale allegati/ {filename} Allegati (da MinIO) ricevute/ {receipt_id}.eml EML ricevute accettazione/consegna Args: eml_bytes: EML grezzo del messaggio principale message_id: UUID del messaggio PecHub subject: Oggetto (per bag-info.txt) from_address: Mittente to_addresses: Destinatari received_at: Data ISO 8601 attachments: Lista di (filename, bytes) per ogni allegato receipts: Lista di (receipt_id, eml_bytes) per ogni ricevuta PEC Returns: Bytes dello ZIP BagIt """ bag_name = f"pechub-pec-{message_id}" eml_filename = f"{message_id}.eml" eml_data_path = f"data/{eml_filename}" eml_sha256 = hashlib.sha256(eml_bytes).hexdigest() bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n" bag_info_lines = [ "Bag-Software-Agent: PecHub Archival Module", f"Bagging-Date: {datetime.now(UTC).strftime('%Y-%m-%d')}", f"External-Identifier: {message_id}", "Source-Organization: PecHub", ] if subject: bag_info_lines.append(f"Description: {subject[:500]}") if from_address: bag_info_lines.append(f"Contact-Email: {from_address}") if to_addresses: bag_info_lines.append(f"External-Description: PEC a {', '.join(to_addresses[:3])}") if received_at: bag_info_lines.append(f"Bag-Group-Identifier: {received_at[:10]}") n_allegati = len(attachments) if attachments else 0 n_ricevute = len(receipts) if receipts else 0 bag_info_lines.append(f"Payload-Oxum: allegati={n_allegati}, ricevute={n_ricevute}") bag_info_txt = "\n".join(bag_info_lines) + "\n" # Costruzione manifest: elenca tutti i file in data/ con i loro checksum manifest_lines = [f"{eml_sha256} {eml_data_path}"] buf = io.BytesIO() with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: # EML principale zf.writestr(f"{bag_name}/{eml_data_path}", eml_bytes) # Allegati if attachments: for filename, att_bytes in attachments: att_path = f"data/allegati/{filename}" att_sha256 = hashlib.sha256(att_bytes).hexdigest() manifest_lines.append(f"{att_sha256} {att_path}") zf.writestr(f"{bag_name}/{att_path}", att_bytes) # Ricevute PEC (accettazione, avvenuta consegna) if receipts: for receipt_id, receipt_eml_bytes in receipts: rec_path = f"data/ricevute/{receipt_id}.eml" rec_sha256 = hashlib.sha256(receipt_eml_bytes).hexdigest() manifest_lines.append(f"{rec_sha256} {rec_path}") zf.writestr(f"{bag_name}/{rec_path}", receipt_eml_bytes) # File di metadati (aggiunti dopo i dati per avere il manifest completo) manifest_txt = "\n".join(manifest_lines) + "\n" zf.writestr(f"{bag_name}/bagit.txt", bagit_txt) zf.writestr(f"{bag_name}/bag-info.txt", bag_info_txt) zf.writestr(f"{bag_name}/manifest-sha256.txt", manifest_txt) return buf.getvalue() # ─── Implementazione AETERNA (JWT + E-ARK BagIt) ───────────────────────────── class AeternaConservatoreClient(_BaseConservatoreClient): """ Client HTTP per Aeterna – piattaforma di conservazione digitale E-ARK. Provider: aeterna.idrainformatica.it Endpoint: https://api.aeterna.idrainformatica.it Standard: E-ARK CSIP 2.1.0, BagIt RFC 8493, PREMIS 3.0, METS 1.12 Autenticazione: POST /api/v1/auth/login → {email, password, tenant_slug} Restituisce JWT Bearer (access_token, expires_in=3600, refresh_token) Ingest (SIP upload): POST /api/v1/ingest/upload → multipart/form-data Campi: file (ZIP BagIt), title (str obbligatorio) Risposta 202: {package_id, pid, status, task_id} Status polling: GET /api/v1/ingest/{package_id}/status status: UPLOADED | VALIDATING | PROCESSING | ACTIVE | FAILED | REJECTED DIP (disseminazione): POST /api/v1/packages/{package_id}/disseminate → {note: null} GET /api/v1/packages/{package_id}/dip → stato DIP GET /api/v1/packages/{package_id}/dip/download → stream ZIP DIP Il pacchetto SIP viene costruito in formato BagIt RFC 8493 (build_bagit_sip). Il formato è auto-rilevato da Aeterna durante l'ingest. """ # Mapping stati Aeterna → stati PecHub _STATUS_MAP = { "UPLOADED": "uploading", "VALIDATING": "uploading", "PROCESSING": "uploading", "INGESTING": "uploading", "ACTIVE": "confirmed", "FAILED": "failed", "REJECTED": "rejected", "DELETED": "rejected", } def __init__( self, endpoint: str, username: str, password: str, tenant_slug: str, conservatore_id: str = "aeterna", timeout_seconds: int = 120, ) -> None: self.endpoint = endpoint.rstrip("/") self.username = username self.password = password self.tenant_slug = tenant_slug self.conservatore_id = conservatore_id self.timeout_seconds = timeout_seconds self._access_token: str | None = None self._token_expires_at: float = 0.0 # ── Autenticazione JWT ───────────────────────────────────────────────────── async def _login(self) -> str: """ Autentica su Aeterna e restituisce un access_token JWT. Endpoint: POST /api/v1/auth/login Body: {email, password, tenant_slug} """ try: import httpx except ImportError: raise RuntimeError("httpx non installato nel worker") async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( f"{self.endpoint}/api/v1/auth/login", json={ "email": self.username, "password": self.password, "tenant_slug": self.tenant_slug, }, ) resp.raise_for_status() data = resp.json() token = data["access_token"] expires_in = int(data.get("expires_in", 3600)) # Rinnova con 60 secondi di anticipo self._access_token = token self._token_expires_at = time.monotonic() + expires_in - 60 return token async def _get_token(self) -> str: """Restituisce il token corrente, eseguendo login se scaduto.""" if self._access_token and time.monotonic() < self._token_expires_at: return self._access_token return await self._login() async def _auth_headers(self) -> dict[str, str]: token = await self._get_token() return {"Authorization": f"Bearer {token}"} # ── Ingest SIP ──────────────────────────────────────────────────────────── async def upload_versamento( self, sip_path: str, sip_bytes: bytes, tenant_id: uuid.UUID, *, subject: str | None = None, from_address: str | None = None, to_addresses: list[str] | None = None, received_at: str | None = None, message_id: str | None = None, ) -> VersamentoResult: """ Costruisce un BagIt SIP e lo carica su Aeterna. Endpoint: POST /api/v1/ingest/upload (multipart/form-data) Risposta: 202 Accepted con package_id da usare per polling. Il versamento_id restituito è il package_id di Aeterna (UUID). """ try: import httpx except ImportError: return VersamentoResult( success=False, message="httpx non installato nel worker.", ) try: headers = await self._auth_headers() # Se sip_bytes è già uno ZIP BagIt lo usiamo direttamente, # altrimenti costruiamo un nuovo BagIt dal contenuto EML. # Convenzione: se sip_path termina con .eml, il contenuto # è un singolo EML grezzo → wrapping BagIt automatico. msg_id = message_id or str(uuid.uuid4()) if sip_path.endswith(".eml") or not sip_path.endswith(".zip"): zip_bytes = build_bagit_sip( eml_bytes=sip_bytes, message_id=msg_id, subject=subject, from_address=from_address, to_addresses=to_addresses, received_at=received_at, ) zip_filename = f"pechub-pec-{msg_id}.zip" else: zip_bytes = sip_bytes zip_filename = sip_path.split("/")[-1] or "sip.zip" title = subject or f"PEC {msg_id}" async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: response = await client.post( f"{self.endpoint}/api/v1/ingest/upload", headers=headers, files={"file": (zip_filename, zip_bytes, "application/zip")}, data={ "title": title[:500], "description": f"Messaggio PEC archiviato da PecHub (tenant {tenant_id})", "creator": "PecHub Archival Module", }, ) if response.status_code in (200, 201, 202): data = response.json() package_id = data.get("package_id") or data.get("id") or str(uuid.uuid4()) return VersamentoResult( success=True, versamento_id=str(package_id), message=f"Ingest avviato su Aeterna (package_id={package_id})", raw_response=data, ) else: return VersamentoResult( success=False, message=f"Aeterna ha risposto 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 ad Aeterna: {e}", ) # ── Polling status ──────────────────────────────────────────────────────── async def get_versamento_status( self, versamento_id: str ) -> VersamentoStatus: """ Polling dello stato di ingest. Endpoint: GET /api/v1/ingest/{package_id}/status Stati Aeterna: UPLOADED, VALIDATING, PROCESSING, ACTIVE, FAILED, REJECTED """ try: import httpx except ImportError: return VersamentoStatus( versamento_id=versamento_id, status="unknown", message="httpx non installato", ) try: headers = await self._auth_headers() async with httpx.AsyncClient(timeout=30) as client: response = await client.get( f"{self.endpoint}/api/v1/ingest/{versamento_id}/status", headers=headers, ) if response.status_code == 200: data = response.json() aeterna_status = data.get("status", "UNKNOWN").upper() pechub_status = self._STATUS_MAP.get(aeterna_status, "uploading") is_accepted = aeterna_status == "ACTIVE" return VersamentoStatus( versamento_id=versamento_id, status=pechub_status, message=data.get("error_message") or f"Aeterna status: {aeterna_status}", rdv_available=is_accepted, raw_response=data, ) elif response.status_code == 404: return VersamentoStatus( versamento_id=versamento_id, status="failed", message="Package non trovato su Aeterna", ) 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}", ) # ── DIP (Dissemination Information Package) ─────────────────────────────── async def get_dip(self, versamento_id: str) -> DipResult: """ Richiede generazione DIP su Aeterna e restituisce i dettagli. Endpoint: POST /api/v1/packages/{package_id}/disseminate Poi poll: GET /api/v1/packages/{package_id}/dip Download: GET /api/v1/packages/{package_id}/dip/download """ try: import httpx except ImportError: return DipResult(success=False, message="httpx non installato") try: headers = await self._auth_headers() # 1. Richiede generazione DIP async with httpx.AsyncClient(timeout=60) as client: resp_disseminate = await client.post( f"{self.endpoint}/api/v1/packages/{versamento_id}/disseminate", headers=headers, json={"note": "Generato da PecHub Archival Module"}, ) if resp_disseminate.status_code not in (200, 201, 202): return DipResult( success=False, message=f"Errore avvio DIP HTTP {resp_disseminate.status_code}: {resp_disseminate.text[:200]}", ) dip_data = resp_disseminate.json() dip_id = dip_data.get("id", "") download_url = ( f"{self.endpoint}/api/v1/packages/{versamento_id}/dip/download" if dip_data.get("is_available") else None ) return DipResult( success=True, dip_id=str(dip_id), download_url=download_url, message="DIP richiesto su Aeterna" if not download_url else "DIP disponibile", ) except Exception as e: return DipResult(success=False, message=f"Errore connessione: {e}") # ── Test connessione ────────────────────────────────────────────────────── async def test_connection(self) -> dict[str, Any]: """ Verifica la connessione ad Aeterna eseguendo login e chiamando /me. Usato dall'endpoint backend POST /settings/test-conservatore. Restituisce {success, message, latency_ms, provider_info}. """ try: import httpx except ImportError: return {"success": False, "message": "httpx non installato", "latency_ms": None} t_start = time.monotonic() try: token = await self._login() headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( f"{self.endpoint}/api/v1/auth/me", headers=headers, ) latency_ms = int((time.monotonic() - t_start) * 1000) if resp.status_code == 200: me = resp.json() return { "success": True, "message": f"Connessione ad Aeterna riuscita (utente: {me.get('email', '?')})", "latency_ms": latency_ms, "provider_info": { "platform": "Aeterna", "tenant_slug": self.tenant_slug, "user_id": me.get("id"), "email": me.get("email"), "permissions": me.get("permissions", []), }, } else: return { "success": False, "message": f"Login riuscito ma /me ha risposto HTTP {resp.status_code}", "latency_ms": latency_ms, } except Exception as e: latency_ms = int((time.monotonic() - t_start) * 1000) return { "success": False, "message": f"Errore connessione ad Aeterna: {e}", "latency_ms": latency_ms, } # ─── Implementazione PRODUZIONE generica (HTTP Basic) ──────────────────────── class ProductionConservatoreClient(_BaseConservatoreClient): """ Client HTTP generico per conservatori AgID con API proprietaria. Autenticazione: HTTP Basic (standard AgID CNIPA vecchio stile). Formato SIP: UNI SInCRO 11386:2023 (pacchetto ZIP con indice XML). NON compatibile con Aeterna (che usa JWT + BagIt). Usa AeternaConservatoreClient per il provider Aeterna. 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 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à e al provider. 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) Provider riconosciuti: conservatore_id == "aeterna" → AeternaConservatoreClient (JWT + BagIt) conservatore_id == altro → ProductionConservatoreClient (HTTP Basic) mode == "mock" → MockConservatoreClient """ @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, tenant_slug, username, password """ mode = creds.get("mode", "mock") if mode == "production": endpoint = creds.get("endpoint") username = creds.get("username") password = creds.get("password") conservatore_id = creds.get("conservatore_id", "production") 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." ) # Aeterna usa JWT + BagIt: riconosciuto da conservatore_id o dall'URL is_aeterna = ( conservatore_id.lower() == "aeterna" or "aeterna" in (endpoint or "").lower() or "idrainformatica" in (endpoint or "").lower() ) if is_aeterna: tenant_slug = creds.get("tenant_slug") if not tenant_slug: raise ValueError( "Provider Aeterna richiede il Tenant Slug. " "Configurare conservatore_tenant_slug nelle impostazioni del tenant." ) return AeternaConservatoreClient( endpoint=endpoint, username=username, password=password, tenant_slug=tenant_slug, conservatore_id=conservatore_id, ) return ProductionConservatoreClient( endpoint=endpoint, username=username, password=password, conservatore_id=conservatore_id, ) # Default: modalità mock (sicura per sviluppo) return MockConservatoreClient()