GapFill Flowee

This commit is contained in:
2026-06-18 11:24:05 +02:00
parent 64442af182
commit c68daf4313
25 changed files with 2965 additions and 48 deletions
+578 -11
View File
@@ -8,9 +8,11 @@ Modalità mock (default in sviluppo):
- 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)
- 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
@@ -20,7 +22,7 @@ Come switchare da mock a produzione:
Interfaccia pubblica (stessa per entrambe le modalità):
client = ConservatoreClient.from_tenant_credentials(creds)
result = await client.upload_versamento(sip_path, sip_bytes)
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)
"""
@@ -28,7 +30,10 @@ Interfaccia pubblica (stessa per entrambe le modalità):
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
@@ -152,15 +157,548 @@ class MockConservatoreClient(_BaseConservatoreClient):
)
# ─── Implementazione PRODUZIONE ───────────────────────────────────────────────
# ─── 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 reale per conservatore AgID.
Client HTTP generico per conservatori AgID con API proprietaria.
Autenticazione: HTTP Basic (standard AgID CNIPA).
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).
"""
@@ -187,7 +725,7 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
) -> VersamentoResult:
"""
POST {endpoint}/versamento
Content-Type: application/octet-stream (o multipart/form-data per alcuni provider)
Content-Type: application/octet-stream
Authorization: Basic base64(user:pass)
"""
try:
@@ -333,12 +871,17 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
class ConservatoreClient:
"""
Factory che istanzia il client corretto in base alla modalità.
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
@@ -348,7 +891,8 @@ class ConservatoreClient:
Args:
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
con chiavi: mode, conservatore_id, endpoint, username, password
con chiavi: mode, conservatore_id, endpoint, tenant_slug,
username, password
"""
mode = creds.get("mode", "mock")
@@ -356,6 +900,7 @@ class ConservatoreClient:
endpoint = creds.get("endpoint")
username = creds.get("username")
password = creds.get("password")
conservatore_id = creds.get("conservatore_id", "production")
if not endpoint:
raise ValueError(
@@ -368,11 +913,33 @@ class ConservatoreClient:
"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=creds.get("conservatore_id", "production"),
conservatore_id=conservatore_id,
)
# Default: modalità mock (sicura per sviluppo)