GapFill Flowee
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user