947 lines
35 KiB
Python
947 lines
35 KiB
Python
"""
|
||
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()
|