Files
PecHub/worker/app/archival/conservatore_client.py
T
2026-06-18 11:24:05 +02:00

947 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()