359 lines
16 KiB
Python
359 lines
16 KiB
Python
"""
|
||
Job arq: run_conservation – conservazione sostitutiva giornaliera.
|
||
|
||
Eseguito ogni giorno alle 18:00 (ora Italia, 16:00 UTC) tramite arq cron.
|
||
|
||
Flusso di esecuzione:
|
||
1. Trova tutti i tenant che hanno messaggi con is_pending_conservation=TRUE
|
||
e is_conserved=FALSE
|
||
2. Per ogni tenant, legge la configurazione conservatore da tenant_settings
|
||
(decifra credenziali se in modalità produzione)
|
||
3. Per ogni messaggio da conservare:
|
||
a. Carica il messaggio con i suoi allegati dal DB
|
||
b. Carica le ricevute associate (accettazione, avvenuta_consegna)
|
||
c. Scarica EML principale da MinIO
|
||
d. Scarica ogni allegato da MinIO
|
||
e. Scarica EML di ogni ricevuta da MinIO (se disponibile)
|
||
f. Costruisce pacchetto BagIt SIP completo
|
||
g. Invia al conservatore
|
||
h. Su successo: imposta is_conserved=TRUE, conserved_at=NOW(),
|
||
is_pending_conservation=FALSE
|
||
i. Su errore: logga l'errore e lascia is_pending_conservation=TRUE
|
||
(verrà ritentato al prossimo run giornaliero)
|
||
|
||
Gestione messaggi senza EML in MinIO:
|
||
Se raw_eml_path è NULL o il download fallisce, viene generato un EML
|
||
sintetico dai metadati del messaggio. La conservazione procede comunque.
|
||
|
||
Idempotenza:
|
||
Il job è idempotente: se eseguito più volte, salta i messaggi già conservati
|
||
(is_conserved=TRUE) perché il filtro WHERE esclude già quei record.
|
||
"""
|
||
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.archival.conservatore_client import (
|
||
ConservatoreClient,
|
||
build_bagit_sip_complete,
|
||
)
|
||
from app.database import AsyncSessionLocal
|
||
from app.models import Attachment, Message, TenantSettings
|
||
from app.security import decrypt_credential
|
||
from app.storage.minio_client import download_attachment as minio_download
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ─── Costanti ─────────────────────────────────────────────────────────────────
|
||
|
||
# Tipi di messaggio considerati "ricevute" da includere nel SIP
|
||
RECEIPT_PEC_TYPES = {"accettazione", "avvenuta_consegna"}
|
||
|
||
# Massimo messaggi processati per run (evita timeout su run iniziali con backlog)
|
||
MAX_MESSAGES_PER_RUN = 200
|
||
|
||
|
||
# ─── Helper: download EML da MinIO ────────────────────────────────────────────
|
||
|
||
async def _download_eml(raw_eml_path: str | None, msg_id: str, subject: str | None,
|
||
from_address: str | None, to_addresses: list[str] | None,
|
||
received_at: datetime | None) -> bytes:
|
||
"""
|
||
Tenta di scaricare l'EML da MinIO. Se non disponibile, genera un EML sintetico.
|
||
"""
|
||
if raw_eml_path:
|
||
try:
|
||
data = await minio_download(raw_eml_path)
|
||
logger.debug(f"[conservation] EML scaricato da MinIO: {raw_eml_path} ({len(data)} bytes)")
|
||
return data
|
||
except Exception as e:
|
||
logger.warning(f"[conservation] Download EML fallito ({raw_eml_path}): {e} – uso EML sintetico")
|
||
|
||
# EML sintetico dai metadati
|
||
recv_str = received_at.isoformat() if received_at else datetime.now(UTC).isoformat()
|
||
to_str = ", ".join(to_addresses) if to_addresses else "destinatario@pec.it"
|
||
synthetic_eml = (
|
||
f"From: {from_address or 'mittente@pec.it'}\r\n"
|
||
f"To: {to_str}\r\n"
|
||
f"Subject: {subject or 'Messaggio PEC'}\r\n"
|
||
f"Date: {recv_str}\r\n"
|
||
f"Message-ID: <{msg_id}@pechub.synthetic>\r\n"
|
||
f"Content-Type: text/plain; charset=UTF-8\r\n"
|
||
f"MIME-Version: 1.0\r\n"
|
||
f"\r\n"
|
||
f"[EML sintetico generato da PecHub – EML originale non disponibile]\r\n"
|
||
f"ID messaggio PecHub: {msg_id}\r\n"
|
||
f"Data conservazione: {datetime.now(UTC).isoformat()}\r\n"
|
||
)
|
||
logger.warning(f"[conservation] EML sintetico generato per messaggio {msg_id}")
|
||
return synthetic_eml.encode("utf-8")
|
||
|
||
|
||
# ─── Helper: credenziali conservatore dal DB ──────────────────────────────────
|
||
|
||
async def _get_conservatore_creds(db, tenant_id: uuid.UUID) -> dict:
|
||
"""
|
||
Legge le impostazioni conservatore del tenant dal DB e decifra le credenziali.
|
||
Restituisce un dict compatibile con ConservatoreClient.from_tenant_credentials().
|
||
"""
|
||
result = await db.execute(
|
||
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||
)
|
||
settings = result.scalar_one_or_none()
|
||
|
||
if settings is None:
|
||
# Nessuna configurazione: usa mock
|
||
return {"mode": "mock", "conservatore_id": "mock"}
|
||
|
||
username = None
|
||
password = None
|
||
|
||
try:
|
||
if settings.conservatore_username_enc:
|
||
username = decrypt_credential(settings.conservatore_username_enc)
|
||
if settings.conservatore_password_enc:
|
||
password = decrypt_credential(settings.conservatore_password_enc)
|
||
except ValueError as e:
|
||
logger.error(f"[conservation] Decifratura credenziali fallita per tenant {tenant_id}: {e}")
|
||
|
||
return {
|
||
"mode": settings.archival_mode,
|
||
"conservatore_id": settings.conservatore_id,
|
||
"endpoint": settings.conservatore_endpoint,
|
||
"tenant_slug": settings.conservatore_tenant_slug,
|
||
"username": username,
|
||
"password": password,
|
||
}
|
||
|
||
|
||
# ─── Job principale ───────────────────────────────────────────────────────────
|
||
|
||
async def run_conservation(ctx: dict[str, Any]) -> dict:
|
||
"""
|
||
Job arq: esegue il ciclo di conservazione sostitutiva per tutti i tenant.
|
||
|
||
Schedulato tramite cron alle 16:00 UTC (18:00 ora Italia).
|
||
|
||
Returns:
|
||
dict con statistiche del run: tenant processati, messaggi conservati,
|
||
messaggi falliti.
|
||
"""
|
||
logger.info("[conservation] Avvio ciclo conservazione sostitutiva")
|
||
|
||
stats = {
|
||
"tenant_count": 0,
|
||
"messages_processed": 0,
|
||
"messages_conserved": 0,
|
||
"messages_failed": 0,
|
||
"started_at": datetime.now(UTC).isoformat(),
|
||
}
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
# ── 1. Trova tutti i tenant con messaggi da conservare ────────────────
|
||
result = await db.execute(
|
||
select(Message.tenant_id)
|
||
.where(
|
||
Message.is_pending_conservation == True, # noqa: E712
|
||
Message.is_conserved == False, # noqa: E712
|
||
)
|
||
.distinct()
|
||
)
|
||
tenant_ids = [row[0] for row in result.fetchall()]
|
||
|
||
if not tenant_ids:
|
||
logger.info("[conservation] Nessun messaggio da conservare")
|
||
stats["finished_at"] = datetime.now(UTC).isoformat()
|
||
return stats
|
||
|
||
logger.info(f"[conservation] Tenant con messaggi pendenti: {len(tenant_ids)}")
|
||
stats["tenant_count"] = len(tenant_ids)
|
||
|
||
# ── 2. Processa ogni tenant ───────────────────────────────────────────
|
||
for tenant_id in tenant_ids:
|
||
logger.info(f"[conservation] Elaborazione tenant {tenant_id}")
|
||
|
||
# Leggi credenziali conservatore
|
||
try:
|
||
creds = await _get_conservatore_creds(db, tenant_id)
|
||
client = ConservatoreClient.from_tenant_credentials(creds)
|
||
conservatore_mode = creds.get("mode", "mock")
|
||
logger.info(
|
||
f"[conservation] Tenant {tenant_id}: "
|
||
f"modalita={conservatore_mode}, "
|
||
f"conservatore={creds.get('conservatore_id', 'mock')}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[conservation] Impossibile inizializzare client conservatore "
|
||
f"per tenant {tenant_id}: {e} – tenant saltato"
|
||
)
|
||
continue
|
||
|
||
# Carica messaggi da conservare con allegati (eager load)
|
||
msgs_result = await db.execute(
|
||
select(Message)
|
||
.where(
|
||
Message.tenant_id == tenant_id,
|
||
Message.is_pending_conservation == True, # noqa: E712
|
||
Message.is_conserved == False, # noqa: E712
|
||
)
|
||
.options(selectinload(Message.attachments))
|
||
.order_by(Message.received_at.asc().nullsfirst())
|
||
.limit(MAX_MESSAGES_PER_RUN)
|
||
)
|
||
messages = msgs_result.scalars().all()
|
||
|
||
logger.info(
|
||
f"[conservation] Tenant {tenant_id}: "
|
||
f"{len(messages)} messaggi da conservare"
|
||
)
|
||
|
||
for msg in messages:
|
||
stats["messages_processed"] += 1
|
||
msg_id = str(msg.id)
|
||
subject = msg.subject or f"PEC {msg_id[:8]}"
|
||
|
||
try:
|
||
# ── a. Scarica EML principale ─────────────────────────────
|
||
eml_bytes = await _download_eml(
|
||
raw_eml_path=msg.raw_eml_path,
|
||
msg_id=msg_id,
|
||
subject=msg.subject,
|
||
from_address=msg.from_address,
|
||
to_addresses=msg.to_addresses,
|
||
received_at=msg.received_at,
|
||
)
|
||
|
||
# ── b. Scarica allegati ───────────────────────────────────
|
||
attachment_files: list[tuple[str, bytes]] = []
|
||
for att in msg.attachments:
|
||
try:
|
||
att_bytes = await minio_download(att.storage_path)
|
||
# Usa il filename originale, sanitizzato per path sicuri
|
||
safe_name = att.filename.replace("/", "_").replace("\\", "_")
|
||
attachment_files.append((safe_name, att_bytes))
|
||
logger.debug(
|
||
f"[conservation] Allegato scaricato: "
|
||
f"{att.filename} ({len(att_bytes)} bytes)"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[conservation] Allegato non disponibile "
|
||
f"({att.filename}): {e} – saltato"
|
||
)
|
||
|
||
# ── c. Carica e scarica ricevute PEC ──────────────────────
|
||
receipts_result = await db.execute(
|
||
select(Message).where(
|
||
Message.parent_message_id == msg.id,
|
||
Message.pec_type.in_(list(RECEIPT_PEC_TYPES)),
|
||
)
|
||
)
|
||
receipt_messages = receipts_result.scalars().all()
|
||
|
||
receipt_files: list[tuple[str, bytes]] = []
|
||
for receipt in receipt_messages:
|
||
if not receipt.raw_eml_path:
|
||
logger.debug(
|
||
f"[conservation] Ricevuta {receipt.id} "
|
||
f"({receipt.pec_type}) senza EML – saltata"
|
||
)
|
||
continue
|
||
try:
|
||
rec_bytes = await minio_download(receipt.raw_eml_path)
|
||
receipt_files.append((str(receipt.id), rec_bytes))
|
||
logger.debug(
|
||
f"[conservation] Ricevuta scaricata: "
|
||
f"{receipt.pec_type} ({len(rec_bytes)} bytes)"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[conservation] Download ricevuta {receipt.id} fallito: "
|
||
f"{e} – saltata"
|
||
)
|
||
|
||
# ── d. Costruisci SIP BagIt completo ──────────────────────
|
||
sip_bytes = build_bagit_sip_complete(
|
||
eml_bytes=eml_bytes,
|
||
message_id=msg_id,
|
||
subject=msg.subject,
|
||
from_address=msg.from_address,
|
||
to_addresses=msg.to_addresses,
|
||
received_at=msg.received_at.isoformat() if msg.received_at else None,
|
||
attachments=attachment_files if attachment_files else None,
|
||
receipts=receipt_files if receipt_files else None,
|
||
)
|
||
|
||
n_att = len(attachment_files)
|
||
n_rec = len(receipt_files)
|
||
sip_size_kb = len(sip_bytes) // 1024
|
||
logger.info(
|
||
f"[conservation] SIP costruito per {msg_id[:8]}... "
|
||
f"({sip_size_kb} KB, {n_att} allegati, {n_rec} ricevute)"
|
||
)
|
||
|
||
# ── e. Upload al conservatore ─────────────────────────────
|
||
sip_filename = f"pechub-pec-{msg_id}.zip"
|
||
upload_result = await client.upload_versamento(
|
||
sip_path=sip_filename,
|
||
sip_bytes=sip_bytes,
|
||
tenant_id=tenant_id,
|
||
)
|
||
|
||
if not upload_result.success:
|
||
raise RuntimeError(
|
||
f"Upload conservatore fallito: {upload_result.message}"
|
||
)
|
||
|
||
versamento_id = upload_result.versamento_id
|
||
logger.info(
|
||
f"[conservation] Messaggio {msg_id[:8]}... conservato: "
|
||
f"versamento_id={versamento_id}"
|
||
)
|
||
|
||
# ── f. Aggiorna DB: messaggio conservato ──────────────────
|
||
await db.execute(
|
||
update(Message)
|
||
.where(Message.id == msg.id)
|
||
.values(
|
||
is_conserved=True,
|
||
conserved_at=datetime.now(UTC),
|
||
is_pending_conservation=False,
|
||
)
|
||
)
|
||
await db.commit()
|
||
|
||
stats["messages_conserved"] += 1
|
||
logger.info(
|
||
f"[conservation] DB aggiornato: "
|
||
f"is_conserved=TRUE, is_pending_conservation=FALSE "
|
||
f"per messaggio {msg_id[:8]}..."
|
||
)
|
||
|
||
except Exception as e:
|
||
await db.rollback()
|
||
stats["messages_failed"] += 1
|
||
logger.error(
|
||
f"[conservation] ERRORE conservazione messaggio {msg_id}: {e}",
|
||
exc_info=True,
|
||
)
|
||
# Il messaggio resta con is_pending_conservation=TRUE
|
||
# e verrà ritentato al prossimo run giornaliero
|
||
|
||
stats["finished_at"] = datetime.now(UTC).isoformat()
|
||
|
||
logger.info(
|
||
f"[conservation] Ciclo completato: "
|
||
f"{stats['messages_conserved']} conservati, "
|
||
f"{stats['messages_failed']} falliti "
|
||
f"su {stats['messages_processed']} processati"
|
||
)
|
||
|
||
return stats
|