GapFill Flowee
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""add conservatore_tenant_slug to tenant_settings
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-06-18
|
||||
|
||||
Aggiunge la colonna conservatore_tenant_slug a tenant_settings,
|
||||
necessaria per i provider che usano autenticazione multi-tenant JWT
|
||||
(es. Aeterna di Idra Informatica) dove il login richiede anche
|
||||
un tenant_slug oltre a email e password.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tenant_settings",
|
||||
sa.Column(
|
||||
"conservatore_tenant_slug",
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
comment="Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tenant_settings", "conservatore_tenant_slug")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""add rem support to mailboxes and messages
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-06-18
|
||||
|
||||
Aggiunge campi per supporto REM (Registered Electronic Mail europea, ETSI EN 319 532-4):
|
||||
- mailboxes.protocol_type VARCHAR(10) DEFAULT 'pec_it'
|
||||
Indica il protocollo della casella: 'pec_it' (PEC italiana) o 'rem_eu' (REM europea)
|
||||
- mailboxes.rem_provider VARCHAR(100) nullable
|
||||
Nome del provider REM europeo (es. 'docutel', 'anodet', 'de-mail')
|
||||
- messages.protocol_type VARCHAR(10) DEFAULT 'pec_it'
|
||||
Protocollo del messaggio, copiato dalla casella al momento della ricezione
|
||||
- messages.rem_evidence_type VARCHAR(100) nullable
|
||||
Tipo evidenza REM grezzo (es. 'SubmissionAcceptance', 'DeliveryInformation')
|
||||
Valorizzato solo per messaggi REM; None per PEC italiane.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Tabella mailboxes ─────────────────────────────────────────────────────
|
||||
op.add_column(
|
||||
"mailboxes",
|
||||
sa.Column(
|
||||
"protocol_type",
|
||||
sa.String(10),
|
||||
nullable=False,
|
||||
server_default="pec_it",
|
||||
comment="Tipo protocollo: pec_it (PEC italiana) | rem_eu (REM europea ETSI EN 319 532-4)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"mailboxes",
|
||||
sa.Column(
|
||||
"rem_provider",
|
||||
sa.String(100),
|
||||
nullable=True,
|
||||
comment="Nome provider REM europeo (es. docutel, anodet, de-mail)",
|
||||
),
|
||||
)
|
||||
|
||||
# ── Tabella messages ──────────────────────────────────────────────────────
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"protocol_type",
|
||||
sa.String(10),
|
||||
nullable=False,
|
||||
server_default="pec_it",
|
||||
comment="Tipo protocollo del messaggio: pec_it | rem_eu",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"rem_evidence_type",
|
||||
sa.String(100),
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Tipo evidenza REM grezzo (es. SubmissionAcceptance, DeliveryInformation). "
|
||||
"Valorizzato solo per messaggi REM (protocol_type = 'rem_eu')."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("mailboxes", "rem_provider")
|
||||
op.drop_column("mailboxes", "protocol_type")
|
||||
op.drop_column("messages", "rem_evidence_type")
|
||||
op.drop_column("messages", "protocol_type")
|
||||
@@ -15,6 +15,7 @@ Solo admin e super_admin possono accedere.
|
||||
Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=<uuid>.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -23,6 +24,7 @@ from fastapi import APIRouter, HTTPException, Query, status
|
||||
from app.config import get_settings as get_app_settings
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.tenant_settings import (
|
||||
ConservatoreTestResult,
|
||||
IndexingJobStatus,
|
||||
IndexingStats,
|
||||
StartReindexRequest,
|
||||
@@ -60,14 +62,20 @@ def _resolve_tenant_id(
|
||||
description=(
|
||||
"Restituisce la configurazione operativa del tenant: "
|
||||
"modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore. "
|
||||
"Il super_admin puo' specificare ?tenant_id=<uuid> per leggere le impostazioni di un tenant arbitrario."
|
||||
),
|
||||
)
|
||||
async def get_settings(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> TenantSettingsResponse:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.get_or_create(current_user.tenant_id)
|
||||
settings = await service.get_or_create(target_tenant_id)
|
||||
return TenantSettingsService.to_response(settings)
|
||||
|
||||
|
||||
@@ -79,20 +87,175 @@ async def get_settings(
|
||||
"Aggiorna la configurazione operativa del tenant. "
|
||||
"Tutti i campi sono opzionali (semantica PATCH). "
|
||||
"Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato. "
|
||||
"Il super_admin puo' specificare ?tenant_id=<uuid> per aggiornare le impostazioni di un tenant arbitrario."
|
||||
),
|
||||
)
|
||||
async def update_settings(
|
||||
body: TenantSettingsUpdate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> TenantSettingsResponse:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update(current_user.tenant_id, body)
|
||||
settings = await service.update(target_tenant_id, body)
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
return TenantSettingsService.to_response(settings)
|
||||
|
||||
|
||||
# ─── Test connessione conservatore ───────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/test-conservatore",
|
||||
response_model=ConservatoreTestResult,
|
||||
summary="Testa la connessione al conservatore configurato",
|
||||
description=(
|
||||
"Verifica che le credenziali salvate per il conservatore siano valide "
|
||||
"effettuando una chiamata di autenticazione reale. "
|
||||
"Supporta Aeterna (JWT) e conservatori generici (HTTP Basic). "
|
||||
"Non modifica alcun dato, non invia pacchetti."
|
||||
),
|
||||
)
|
||||
async def test_conservatore_connection(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> ConservatoreTestResult:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = TenantSettingsService(db)
|
||||
creds = await service.get_conservatore_credentials(target_tenant_id)
|
||||
|
||||
if creds.get("mode") != "production":
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message="La modalita' di archiviazione e' impostata su 'mock'. "
|
||||
"Configura l'endpoint e le credenziali, poi imposta la modalita' su 'produzione'.",
|
||||
)
|
||||
|
||||
endpoint = creds.get("endpoint")
|
||||
username = creds.get("username")
|
||||
password = creds.get("password")
|
||||
tenant_slug = creds.get("tenant_slug")
|
||||
conservatore_id = creds.get("conservatore_id", "")
|
||||
|
||||
if not endpoint or not username or not password:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message="Credenziali o endpoint non configurati. "
|
||||
"Compila tutti i campi obbligatori prima di testare la connessione.",
|
||||
)
|
||||
|
||||
# Rileva Aeterna da conservatore_id o URL
|
||||
is_aeterna = (
|
||||
(conservatore_id or "").lower() == "aeterna"
|
||||
or "aeterna" in (endpoint or "").lower()
|
||||
or "idrainformatica" in (endpoint or "").lower()
|
||||
)
|
||||
|
||||
t_start = time.monotonic()
|
||||
|
||||
if is_aeterna:
|
||||
# Test Aeterna: POST /api/v1/auth/login + GET /api/v1/auth/me
|
||||
if not tenant_slug:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message="Provider Aeterna richiede il campo 'Tenant Slug'. Configuralo nelle impostazioni.",
|
||||
)
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp_login = await client.post(
|
||||
f"{endpoint.rstrip('/')}/api/v1/auth/login",
|
||||
json={
|
||||
"email": username,
|
||||
"password": password,
|
||||
"tenant_slug": tenant_slug,
|
||||
},
|
||||
)
|
||||
|
||||
latency_ms = int((time.monotonic() - t_start) * 1000)
|
||||
|
||||
if resp_login.status_code != 200:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message=f"Login Aeterna fallito (HTTP {resp_login.status_code}): {resp_login.text[:200]}",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
login_data = resp_login.json()
|
||||
token = login_data.get("access_token")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp_me = await client.get(
|
||||
f"{endpoint.rstrip('/')}/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
latency_ms = int((time.monotonic() - t_start) * 1000)
|
||||
me = resp_me.json() if resp_me.status_code == 200 else {}
|
||||
|
||||
return ConservatoreTestResult(
|
||||
success=resp_me.status_code == 200,
|
||||
message=(
|
||||
f"Connessione ad Aeterna riuscita (utente: {me.get('email', '?')})"
|
||||
if resp_me.status_code == 200
|
||||
else f"Login riuscito ma /me ha risposto HTTP {resp_me.status_code}"
|
||||
),
|
||||
latency_ms=latency_ms,
|
||||
provider_info={
|
||||
"platform": "Aeterna",
|
||||
"tenant_slug": tenant_slug,
|
||||
"user_email": me.get("email"),
|
||||
"permissions_count": len(me.get("permissions", [])),
|
||||
} if me else None,
|
||||
)
|
||||
except Exception as e:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message=f"Errore connessione ad Aeterna: {e}",
|
||||
latency_ms=int((time.monotonic() - t_start) * 1000),
|
||||
)
|
||||
|
||||
else:
|
||||
# Test generico: HTTP Basic HEAD o GET sull'endpoint
|
||||
import base64
|
||||
import httpx
|
||||
auth_str = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Basic {auth_str}"},
|
||||
)
|
||||
latency_ms = int((time.monotonic() - t_start) * 1000)
|
||||
|
||||
if response.status_code < 500:
|
||||
return ConservatoreTestResult(
|
||||
success=True,
|
||||
message=f"Endpoint raggiungibile (HTTP {response.status_code})",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
else:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message=f"Endpoint ha risposto con errore HTTP {response.status_code}",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
except Exception as e:
|
||||
return ConservatoreTestResult(
|
||||
success=False,
|
||||
message=f"Errore connessione: {e}",
|
||||
latency_ms=int((time.monotonic() - t_start) * 1000),
|
||||
)
|
||||
|
||||
|
||||
# ─── Indicizzazione full-text ─────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -65,6 +65,12 @@ class Mailbox(Base):
|
||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Protocollo (Feature N8 – REM europea)
|
||||
# 'pec_it' → PEC italiana (default)
|
||||
# 'rem_eu' → REM europea (ETSI EN 319 532-4)
|
||||
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
|
||||
rem_provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
@@ -115,6 +115,12 @@ class Message(Base):
|
||||
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
|
||||
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True)
|
||||
|
||||
# Protocollo e REM europea (Feature N8)
|
||||
# protocol_type: 'pec_it' (default) o 'rem_eu'
|
||||
# rem_evidence_type: valore grezzo dell'header X-REM-Evidence-Type
|
||||
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
|
||||
rem_evidence_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
|
||||
|
||||
@@ -51,6 +51,9 @@ class TenantSettings(Base):
|
||||
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna)
|
||||
conservatore_tenant_slug: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Note operative opzionali
|
||||
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@ class MailboxCreateRequest(BaseModel):
|
||||
display_name: str | None = Field(None, max_length=255, description="Nome visualizzato")
|
||||
provider: str | None = Field(None, max_length=100, description="Provider PEC (aruba, namirial...)")
|
||||
|
||||
# Protocollo (Feature N8 – REM europea)
|
||||
protocol_type: Literal["pec_it", "rem_eu"] = Field(
|
||||
"pec_it",
|
||||
description="Tipo protocollo: pec_it (PEC italiana, default) | rem_eu (REM europea ETSI EN 319 532-4)",
|
||||
)
|
||||
rem_provider: str | None = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Nome provider REM europeo (es. docutel, anodet, de-mail). Solo per rem_eu.",
|
||||
)
|
||||
|
||||
# Credenziali IMAP (in chiaro, cifrate prima della persistenza)
|
||||
imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP")
|
||||
imap_port: int = Field(993, ge=1, le=65535, description="Porta IMAP")
|
||||
@@ -48,6 +59,10 @@ class MailboxUpdateRequest(BaseModel):
|
||||
provider: str | None = Field(None, max_length=100)
|
||||
status: Literal["active", "paused"] | None = None
|
||||
|
||||
# Aggiornamento protocollo (Feature N8)
|
||||
protocol_type: Literal["pec_it", "rem_eu"] | None = None
|
||||
rem_provider: str | None = Field(None, max_length=100)
|
||||
|
||||
# Aggiornamento credenziali IMAP (opzionale)
|
||||
imap_host: str | None = Field(None, min_length=1, max_length=255)
|
||||
imap_port: int | None = Field(None, ge=1, le=65535)
|
||||
@@ -84,6 +99,10 @@ class MailboxResponse(BaseModel):
|
||||
smtp_port: int
|
||||
smtp_use_tls: bool
|
||||
|
||||
# Protocollo (Feature N8)
|
||||
protocol_type: str
|
||||
rem_provider: str | None
|
||||
|
||||
# Stato sync
|
||||
status: str
|
||||
last_sync_at: datetime | None
|
||||
|
||||
@@ -28,6 +28,7 @@ class TenantSettingsResponse(BaseModel):
|
||||
archival_mode: ArchivalMode
|
||||
conservatore_id: str
|
||||
conservatore_endpoint: str | None
|
||||
conservatore_tenant_slug: str | None
|
||||
conservatore_username_configured: bool # TRUE se la username è già salvata
|
||||
conservatore_password_configured: bool # TRUE se la password è già salvata
|
||||
archival_notes: str | None
|
||||
@@ -56,6 +57,9 @@ class TenantSettingsUpdate(BaseModel):
|
||||
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
|
||||
conservatore_endpoint: str | None = None
|
||||
|
||||
# Slug tenant sul sistema del conservatore (es. 'pechub' per Aeterna)
|
||||
conservatore_tenant_slug: str | None = None
|
||||
|
||||
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
|
||||
# Valore stringa vuota ("") = cancella la credenziale.
|
||||
conservatore_username: str | None = None
|
||||
@@ -103,6 +107,14 @@ class IndexingJobStatus(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ConservatoreTestResult(BaseModel):
|
||||
"""Risposta POST /settings/test-conservatore."""
|
||||
success: bool
|
||||
message: str
|
||||
latency_ms: int | None = None
|
||||
provider_info: dict | None = None
|
||||
|
||||
|
||||
class StartReindexRequest(BaseModel):
|
||||
"""Body per POST /settings/indexing/reindex."""
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ class TenantSettingsService:
|
||||
if data.conservatore_endpoint is not None:
|
||||
settings.conservatore_endpoint = data.conservatore_endpoint or None
|
||||
|
||||
if data.conservatore_tenant_slug is not None:
|
||||
settings.conservatore_tenant_slug = data.conservatore_tenant_slug or None
|
||||
|
||||
if data.archival_notes is not None:
|
||||
settings.archival_notes = data.archival_notes or None
|
||||
|
||||
@@ -118,6 +121,7 @@ class TenantSettingsService:
|
||||
archival_mode=settings.archival_mode, # type: ignore[arg-type]
|
||||
conservatore_id=settings.conservatore_id,
|
||||
conservatore_endpoint=settings.conservatore_endpoint,
|
||||
conservatore_tenant_slug=settings.conservatore_tenant_slug,
|
||||
conservatore_username_configured=settings.conservatore_username_enc is not None,
|
||||
conservatore_password_configured=settings.conservatore_password_enc is not None,
|
||||
archival_notes=settings.archival_notes,
|
||||
@@ -139,6 +143,7 @@ class TenantSettingsService:
|
||||
"mode": settings.archival_mode,
|
||||
"conservatore_id": settings.conservatore_id,
|
||||
"endpoint": settings.conservatore_endpoint,
|
||||
"tenant_slug": settings.conservatore_tenant_slug,
|
||||
"username": (
|
||||
decrypt_credential(settings.conservatore_username_enc)
|
||||
if settings.conservatore_username_enc
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
# Integrazione Aeterna – Archiviazione Sostitutiva PecHub
|
||||
|
||||
Documento tecnico sull'integrazione di PecHub con Aeterna,
|
||||
piattaforma di conservazione digitale conforme E-ARK gestita da Idra Informatica.
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
1. [Panoramica del provider](#1-panoramica-del-provider)
|
||||
2. [Architettura Aeterna](#2-architettura-aeterna)
|
||||
3. [Autenticazione JWT](#3-autenticazione-jwt)
|
||||
4. [Ingest – Upload SIP](#4-ingest--upload-sip)
|
||||
5. [Formato SIP BagIt (RFC 8493)](#5-formato-sip-bagit-rfc-8493)
|
||||
6. [Polling stato ingest](#6-polling-stato-ingest)
|
||||
7. [Disseminazione (DIP)](#7-disseminazione-dip)
|
||||
8. [Mapping stati Aeterna → PecHub](#8-mapping-stati-aeterna--pechub)
|
||||
9. [Configurazione in PecHub](#9-configurazione-in-pechub)
|
||||
10. [Esempi curl completi](#10-esempi-curl-completi)
|
||||
11. [Note operative](#11-note-operative)
|
||||
|
||||
---
|
||||
|
||||
## 1. Panoramica del provider
|
||||
|
||||
| Campo | Valore |
|
||||
|-------------------|-----------------------------------------------|
|
||||
| Provider | Idra Informatica srl |
|
||||
| Piattaforma | Aeterna v0.1.0 |
|
||||
| URL applicazione | https://aeterna.idrainformatica.it |
|
||||
| Endpoint API | https://api.aeterna.idrainformatica.it |
|
||||
| Documentazione | https://api.aeterna.idrainformatica.it/docs |
|
||||
| OpenAPI JSON | https://api.aeterna.idrainformatica.it/openapi.json |
|
||||
| Standard | E-ARK CSIP 2.1.0, BagIt RFC 8493, PREMIS 3.0, METS 1.12 |
|
||||
|
||||
### Credenziali tenant PecHub
|
||||
|
||||
| Campo | Valore |
|
||||
|-------------|---------------------------------|
|
||||
| Org. name | pechub |
|
||||
| Tenant slug | pechub |
|
||||
| Username | matteo@idrainformatica.it |
|
||||
| Password | (cifrata nel DB PecHub) |
|
||||
| Tenant ID | 366d3d51-b25d-46fc-8f9c-9bc28d902620 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architettura Aeterna
|
||||
|
||||
Aeterna e' una piattaforma multi-tenant FastAPI (Python).
|
||||
Ogni organizzazione (tenant) ha:
|
||||
- Container MinIO dedicato per lo storage isolato
|
||||
- Collection Apache Solr dedicata per la ricerca full-text
|
||||
- RBAC (Role-Based Access Control) per-tenant
|
||||
|
||||
Il ciclo di vita di un documento archiviale in Aeterna:
|
||||
|
||||
```
|
||||
SIP (upload) → AIP (ingest pipeline) → DIP (disseminazione)
|
||||
| | |
|
||||
BagIt ZIP PREMIS events ZIP scaricabile
|
||||
multipart METS 1.12 generato
|
||||
form-data validazione E-ARK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Autenticazione JWT
|
||||
|
||||
Aeterna usa JWT Bearer token, NON HTTP Basic.
|
||||
Il token ha durata 3600 secondi (1 ora). Esiste un refresh token.
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"email": "matteo@idrainformatica.it",
|
||||
"password": "...",
|
||||
"tenant_slug": "pechub"
|
||||
}
|
||||
```
|
||||
|
||||
Risposta 200:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": "e3cac60b-d942-4590-94fe-932c0e14e836",
|
||||
"email": "matteo@idrainformatica.it",
|
||||
"full_name": "Matteo Giustini",
|
||||
"is_platform_admin": false,
|
||||
"tenant_id": "366d3d51-b25d-46fc-8f9c-9bc28d902620",
|
||||
"permissions": [
|
||||
"ingest:submit", "ingest:manage", "packages:read",
|
||||
"packages:create", "dissemination:read", "dissemination:download",
|
||||
"preservation:manage", "audit:read", "settings:manage", ...
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Utilizzo del token nelle richieste successive:
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
### Refresh token
|
||||
|
||||
```
|
||||
POST /api/v1/auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{"refresh_token": "eyJhbGciOiJIUzI1NiIs..."}
|
||||
```
|
||||
|
||||
### Verifica identita'
|
||||
|
||||
```
|
||||
GET /api/v1/auth/me
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Ingest – Upload SIP
|
||||
|
||||
L'endpoint di ingest accetta pacchetti SIP in due formati:
|
||||
- **E-ARK CSIP v2.2.0** (ZIP con METS.xml in root)
|
||||
- **BagIt RFC 8493** (ZIP con bagit.txt + data/) — **formato scelto per PecHub**
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST /api/v1/ingest/upload
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
### Campi form-data
|
||||
|
||||
| Campo | Tipo | Obbligatorio | Descrizione |
|
||||
|---------------------|--------|:------------:|--------------------------------------|
|
||||
| `file` | file | si | ZIP SIP (E-ARK CSIP o BagIt) |
|
||||
| `title` | string | si | Titolo del pacchetto |
|
||||
| `description` | string | no | Descrizione |
|
||||
| `creator` | string | no | Nome del produttore |
|
||||
| `submission_agreement` | string | no | ID o URL dell'accordo di versamento |
|
||||
| `ead3_file` | file | no | EAD3 finding aid XML (GAP-09) |
|
||||
| `eac_cpf_file` | file | no | EAC-CPF authority record XML |
|
||||
|
||||
### Risposta 202 Accepted
|
||||
|
||||
```json
|
||||
{
|
||||
"package_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"pid": "urn:pechub:aip:a1b2c3d4",
|
||||
"status": "UPLOADED",
|
||||
"task_id": "celery-task-uuid",
|
||||
"message": "SIP uploaded successfully. Processing started.",
|
||||
"submitted_at": "2026-06-18T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Il `package_id` e' l'identificatore da usare per polling e DIP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Formato SIP BagIt (RFC 8493)
|
||||
|
||||
PecHub costruisce pacchetti BagIt in memoria (`build_bagit_sip` in `conservatore_client.py`).
|
||||
|
||||
### Struttura ZIP generata
|
||||
|
||||
```
|
||||
pechub-pec-{message_id}/
|
||||
bagit.txt # Dichiarazione BagIt (obbligatorio)
|
||||
bag-info.txt # Metadati descrittivi (opzionale)
|
||||
manifest-sha256.txt # Checksum SHA-256 dei file in data/
|
||||
data/
|
||||
{message_id}.eml # Messaggio PEC grezzo
|
||||
```
|
||||
|
||||
### Contenuto bagit.txt
|
||||
|
||||
```
|
||||
BagIt-Version: 1.0
|
||||
Tag-File-Character-Encoding: UTF-8
|
||||
```
|
||||
|
||||
### Contenuto bag-info.txt (esempio)
|
||||
|
||||
```
|
||||
Bag-Software-Agent: PecHub Archival Module
|
||||
Bagging-Date: 2026-06-18
|
||||
External-Identifier: a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||
Source-Organization: PecHub
|
||||
Description: PEC oggetto del messaggio (max 500 char)
|
||||
Contact-Email: mittente@pec.it
|
||||
External-Description: PEC a destinatario@pec.it
|
||||
Bag-Group-Identifier: 2026-06-18
|
||||
```
|
||||
|
||||
### Contenuto manifest-sha256.txt
|
||||
|
||||
```
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/a1b2c3d4-e5f6.eml
|
||||
```
|
||||
|
||||
### Rilevamento automatico BagIt da Aeterna
|
||||
|
||||
Aeterna rileva automaticamente il formato BagIt dalla presenza di `bagit.txt`
|
||||
nella root del bag all'interno dello ZIP. Non e' necessario specificare il formato.
|
||||
La pipeline verifica i checksum del manifest prima dell'ingest.
|
||||
|
||||
---
|
||||
|
||||
## 6. Polling stato ingest
|
||||
|
||||
Dopo l'upload, la pipeline di Aeterna processa il pacchetto in modo asincrono.
|
||||
|
||||
### Endpoint status
|
||||
|
||||
```
|
||||
GET /api/v1/ingest/{package_id}/status
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Risposta
|
||||
|
||||
```json
|
||||
{
|
||||
"package_id": "a1b2c3d4-...",
|
||||
"pid": "urn:pechub:aip:a1b2c3d4",
|
||||
"status": "PROCESSING",
|
||||
"task_id": "celery-task-uuid",
|
||||
"pipeline_stage": "format_identification",
|
||||
"progress_pct": 40,
|
||||
"steps": [
|
||||
{"name": "validation", "status": "completed", ...},
|
||||
{"name": "format_identification", "status": "running", ...},
|
||||
{"name": "virus_scan", "status": "pending", ...}
|
||||
],
|
||||
"is_valid": null,
|
||||
"validation_errors": 0,
|
||||
"error_message": null,
|
||||
"submitted_at": "2026-06-18T09:00:00Z",
|
||||
"completed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
### Stati possibili
|
||||
|
||||
| Status Aeterna | Significato | Stato PecHub |
|
||||
|----------------|------------------------------------------|--------------|
|
||||
| `UPLOADED` | SIP ricevuto, elaborazione non iniziata | `uploading` |
|
||||
| `VALIDATING` | Validazione E-ARK/BagIt in corso | `uploading` |
|
||||
| `PROCESSING` | Pipeline ingest in corso | `uploading` |
|
||||
| `INGESTING` | AIP in fase di creazione | `uploading` |
|
||||
| `ACTIVE` | AIP attivo, conservazione completata | `confirmed` |
|
||||
| `FAILED` | Pipeline fallita con errori | `failed` |
|
||||
| `REJECTED` | Pacchetto non conforme agli standard | `rejected` |
|
||||
| `DELETED` | Soft-delete | `rejected` |
|
||||
|
||||
### Report completo
|
||||
|
||||
```
|
||||
GET /api/v1/ingest/{package_id}/report
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Disseminazione (DIP)
|
||||
|
||||
### Richiesta generazione DIP
|
||||
|
||||
```
|
||||
POST /api/v1/packages/{package_id}/disseminate
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"note": "Richiesto da PecHub Archival Module"}
|
||||
```
|
||||
|
||||
Risposta 202:
|
||||
```json
|
||||
{
|
||||
"id": "dip-uuid",
|
||||
"pid": "urn:pechub:dip:...",
|
||||
"status": "PROCESSING",
|
||||
"size_bytes": null,
|
||||
"is_available": false
|
||||
}
|
||||
```
|
||||
|
||||
### Verifica stato DIP
|
||||
|
||||
```
|
||||
GET /api/v1/packages/{package_id}/dip
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Quando `is_available: true`, il DIP e' scaricabile.
|
||||
|
||||
### Download DIP
|
||||
|
||||
```
|
||||
GET /api/v1/packages/{package_id}/dip/download
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Risposta: stream ZIP del DIP.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mapping stati Aeterna → PecHub
|
||||
|
||||
```python
|
||||
_STATUS_MAP = {
|
||||
"UPLOADED": "uploading",
|
||||
"VALIDATING": "uploading",
|
||||
"PROCESSING": "uploading",
|
||||
"INGESTING": "uploading",
|
||||
"ACTIVE": "confirmed", # conservazione completata
|
||||
"FAILED": "failed",
|
||||
"REJECTED": "rejected",
|
||||
"DELETED": "rejected",
|
||||
}
|
||||
```
|
||||
|
||||
Il `versamento_id` in PecHub corrisponde al `package_id` di Aeterna (UUID v4).
|
||||
|
||||
---
|
||||
|
||||
## 9. Configurazione in PecHub
|
||||
|
||||
### Impostazioni tenant
|
||||
|
||||
Dalla pagina **Impostazioni → Archiviazione Sostitutiva**:
|
||||
|
||||
| Campo | Valore per Aeterna |
|
||||
|-------------------------|---------------------------------------------|
|
||||
| Modalita' | Produzione |
|
||||
| Identificativo conservatore | `aeterna` |
|
||||
| URL endpoint API | `https://api.aeterna.idrainformatica.it` |
|
||||
| Tenant Slug | `pechub` |
|
||||
| Username | `matteo@idrainformatica.it` |
|
||||
| Password | (da fornire, viene cifrata AES-256-GCM) |
|
||||
|
||||
### Riconoscimento automatico del provider
|
||||
|
||||
Il factory `ConservatoreClient.from_tenant_credentials()` rileva Aeterna se:
|
||||
- `conservatore_id == "aeterna"` (case-insensitive), OPPURE
|
||||
- `"aeterna"` e' presente nell'URL endpoint, OPPURE
|
||||
- `"idrainformatica"` e' presente nell'URL endpoint
|
||||
|
||||
Se rilevato come Aeterna, usa `AeternaConservatoreClient` (JWT + BagIt).
|
||||
Altrimenti usa `ProductionConservatoreClient` (HTTP Basic, standard AgID legacy).
|
||||
|
||||
### Test connessione
|
||||
|
||||
Dopo aver configurato e salvato le impostazioni, usare il pulsante
|
||||
**"Testa connessione"** in Impostazioni → Archiviazione Sostitutiva.
|
||||
|
||||
Oppure via API:
|
||||
```
|
||||
POST /api/v1/settings/test-conservatore
|
||||
Authorization: Bearer <pechub-token>
|
||||
```
|
||||
|
||||
Risposta:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Connessione ad Aeterna riuscita (utente: matteo@idrainformatica.it)",
|
||||
"latency_ms": 342,
|
||||
"provider_info": {
|
||||
"platform": "Aeterna",
|
||||
"tenant_slug": "pechub",
|
||||
"user_email": "matteo@idrainformatica.it",
|
||||
"permissions_count": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Esempi curl completi
|
||||
|
||||
### Login
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://api.aeterna.idrainformatica.it/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"matteo@idrainformatica.it","password":"...","tenant_slug":"pechub"}' \
|
||||
| jq -r .access_token)
|
||||
echo "Token: $TOKEN"
|
||||
```
|
||||
|
||||
### Upload SIP BagIt
|
||||
|
||||
```bash
|
||||
# Crea un BagIt minimo di test
|
||||
mkdir -p /tmp/testbag/data
|
||||
echo "Hello PEC" > /tmp/testbag/data/test.eml
|
||||
SHA=$(sha256sum /tmp/testbag/data/test.eml | awk '{print $1}')
|
||||
|
||||
cat > /tmp/testbag/bagit.txt <<EOF
|
||||
BagIt-Version: 1.0
|
||||
Tag-File-Character-Encoding: UTF-8
|
||||
EOF
|
||||
|
||||
cat > /tmp/testbag/manifest-sha256.txt <<EOF
|
||||
$SHA data/test.eml
|
||||
EOF
|
||||
|
||||
# ZIP del bag
|
||||
cd /tmp && zip -r testbag.zip testbag/
|
||||
|
||||
# Upload
|
||||
curl -s -X POST https://api.aeterna.idrainformatica.it/api/v1/ingest/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@/tmp/testbag.zip;type=application/zip" \
|
||||
-F "title=Test PEC da PecHub" \
|
||||
-F "description=Messaggio di test" \
|
||||
-F "creator=PecHub" \
|
||||
| jq .
|
||||
```
|
||||
|
||||
### Polling status
|
||||
|
||||
```bash
|
||||
PACKAGE_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
|
||||
curl -s https://api.aeterna.idrainformatica.it/api/v1/ingest/$PACKAGE_ID/status \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| jq '{status:.status, stage:.pipeline_stage, pct:.progress_pct}'
|
||||
```
|
||||
|
||||
### Lista pacchetti
|
||||
|
||||
```bash
|
||||
curl -s "https://api.aeterna.idrainformatica.it/api/v1/packages?limit=10" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| jq '.items[] | {id:.id, title:.title, status:.status}'
|
||||
```
|
||||
|
||||
### Report ingest
|
||||
|
||||
```bash
|
||||
curl -s https://api.aeterna.idrainformatica.it/api/v1/ingest/$PACKAGE_ID/report \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| jq .
|
||||
```
|
||||
|
||||
### Richiesta DIP
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
"https://api.aeterna.idrainformatica.it/api/v1/packages/$PACKAGE_ID/disseminate" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"note":"Richiesto da PecHub"}' \
|
||||
| jq .
|
||||
```
|
||||
|
||||
### Download DIP
|
||||
|
||||
```bash
|
||||
curl -L -o /tmp/dip.zip \
|
||||
"https://api.aeterna.idrainformatica.it/api/v1/packages/$PACKAGE_ID/dip/download" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Note operative
|
||||
|
||||
### Dimensione pacchetti
|
||||
|
||||
Non ci sono limiti documentati. I file EML di PEC sono tipicamente 10-500 KB.
|
||||
Il timeout HTTP nel client e' impostato a 120 secondi per l'upload.
|
||||
|
||||
### Concorrenza
|
||||
|
||||
Ogni `AeternaConservatoreClient` gestisce il token JWT in memoria.
|
||||
Se il worker usa piu' istanze concorrenti, ogni istanza effettua il suo login.
|
||||
Il token dura 3600s, il rinnovo automatico avviene 60s prima della scadenza.
|
||||
|
||||
### Retry policy
|
||||
|
||||
In caso di fallimento dell'upload, il batch `ArchivalBatch` in PecHub
|
||||
ha `max_attempts=3` e un `next_retry_at` con back-off esponenziale.
|
||||
|
||||
### Standard di riferimento
|
||||
|
||||
- **E-ARK CSIP v2.1.0** — Common Specification for Information Packages
|
||||
- **E-ARK SIP/AIP/DIP** — Submission/Archival/Dissemination Information Package
|
||||
- **BagIt RFC 8493** — formato file standard per trasferimento dati
|
||||
- **PREMIS 3.0** — metadati di preservazione
|
||||
- **METS 1.12** — Metadata Encoding and Transmission Standard
|
||||
- **Dublin Core / EAD** — metadati descrittivi
|
||||
|
||||
### Codice sorgente rilevante
|
||||
|
||||
| File | Descrizione |
|
||||
|------|-------------|
|
||||
| `worker/app/archival/conservatore_client.py` | Client completo con AeternaConservatoreClient, BagIt builder, factory |
|
||||
| `worker/scripts/test_aeterna_transmission.py` | Script di test standalone |
|
||||
| `backend/app/api/v1/settings.py` | Endpoint test-conservatore |
|
||||
| `backend/app/models/tenant_settings.py` | Modello DB con conservatore_tenant_slug |
|
||||
| `backend/app/services/tenant_settings_service.py` | Servizio credenziali |
|
||||
| `backend/alembic/versions/0020_add_conservatore_tenant_slug.py` | Migrazione DB |
|
||||
| `frontend/src/pages/Settings/SettingsPage.tsx` | UI configurazione conservatore |
|
||||
| `frontend/src/api/settings.api.ts` | Client API frontend |
|
||||
|
||||
### Esecuzione script di test
|
||||
|
||||
```bash
|
||||
# Dal server, dentro il container worker
|
||||
docker exec -it pechub-worker-1 \
|
||||
python /app/scripts/test_aeterna_transmission.py
|
||||
|
||||
# I risultati vengono salvati in /tmp/aeterna_test_results.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Documento generato il 2026-06-18. Versione Aeterna: 0.1.0.*
|
||||
@@ -22,6 +22,8 @@ export interface TenantSettingsResponse {
|
||||
archival_mode: ArchivalMode
|
||||
conservatore_id: string
|
||||
conservatore_endpoint: string | null
|
||||
/** Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna) */
|
||||
conservatore_tenant_slug: string | null
|
||||
conservatore_username_configured: boolean
|
||||
conservatore_password_configured: boolean
|
||||
archival_notes: string | null
|
||||
@@ -34,6 +36,8 @@ export interface TenantSettingsUpdate {
|
||||
conservatore_id?: string
|
||||
/** URL endpoint API del conservatore (obbligatorio in produzione) */
|
||||
conservatore_endpoint?: string
|
||||
/** Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna) */
|
||||
conservatore_tenant_slug?: string
|
||||
/** Username in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||
conservatore_username?: string
|
||||
/** Password in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||
@@ -41,6 +45,13 @@ export interface TenantSettingsUpdate {
|
||||
archival_notes?: string
|
||||
}
|
||||
|
||||
export interface ConservatoreTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms: number | null
|
||||
provider_info: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
|
||||
|
||||
export interface IndexingStats {
|
||||
@@ -76,20 +87,35 @@ export interface IndexingJobStatus {
|
||||
|
||||
export const settingsApi = {
|
||||
/**
|
||||
* Recupera le impostazioni del tenant corrente.
|
||||
* Recupera le impostazioni del tenant.
|
||||
* Se non esistono, il backend le crea con i valori di default (mock).
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
get: async (): Promise<TenantSettingsResponse> => {
|
||||
const { data } = await apiClient.get<TenantSettingsResponse>('/settings')
|
||||
get: async (tenantId?: string): Promise<TenantSettingsResponse> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.get<TenantSettingsResponse>('/settings', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Aggiorna le impostazioni del tenant.
|
||||
* Solo i campi forniti vengono modificati (semantica PATCH).
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
update: async (payload: TenantSettingsUpdate): Promise<TenantSettingsResponse> => {
|
||||
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
|
||||
update: async (payload: TenantSettingsUpdate, tenantId?: string): Promise<TenantSettingsResponse> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload, { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Testa la connessione al conservatore configurato.
|
||||
* Effettua una chiamata reale (login + verifica identità) senza modificare dati.
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
testConservatore: async (tenantId?: string): Promise<ConservatoreTestResult> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.post<ConservatoreTestResult>('/settings/test-conservatore', undefined, { params })
|
||||
return data
|
||||
},
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@@ -174,6 +175,14 @@ export function MailboxesPage() {
|
||||
{mailbox.provider}
|
||||
</span>
|
||||
)}
|
||||
{/* Badge REM europea (Feature N8) */}
|
||||
{mailbox.protocol_type === 'rem_eu' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100">
|
||||
<Globe className="h-3 w-3" />
|
||||
REM EU
|
||||
{mailbox.rem_provider && ` · ${mailbox.rem_provider}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{mailbox.email_address}</p>
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
@@ -278,6 +287,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<MailboxCreateRequest>({
|
||||
defaultValues: editingMailbox
|
||||
@@ -285,6 +295,8 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
||||
email_address: editingMailbox.email_address,
|
||||
display_name: editingMailbox.display_name || '',
|
||||
provider: editingMailbox.provider || '',
|
||||
protocol_type: editingMailbox.protocol_type || 'pec_it',
|
||||
rem_provider: editingMailbox.rem_provider || '',
|
||||
imap_host: editingMailbox.imap_host,
|
||||
imap_port: editingMailbox.imap_port,
|
||||
imap_user: editingMailbox.email_address,
|
||||
@@ -295,6 +307,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
||||
smtp_use_tls: editingMailbox.smtp_use_tls,
|
||||
}
|
||||
: {
|
||||
protocol_type: 'pec_it',
|
||||
imap_port: 993,
|
||||
smtp_port: 465,
|
||||
imap_use_ssl: true,
|
||||
@@ -302,6 +315,10 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
||||
},
|
||||
})
|
||||
|
||||
// Osserva il campo protocol_type per mostrare/nascondere rem_provider
|
||||
const selectedProtocol = useWatch({ control, name: 'protocol_type' })
|
||||
const isRemEu = selectedProtocol === 'rem_eu'
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: mailboxesApi.create,
|
||||
onSuccess: () => {
|
||||
@@ -363,6 +380,41 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Protocollo (Feature N8 – REM europea) */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
Protocollo
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tipo protocollo</Label>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
{...register('protocol_type')}
|
||||
>
|
||||
<option value="pec_it">PEC italiana (default)</option>
|
||||
<option value="rem_eu">REM europea (ETSI EN 319 532-4)</option>
|
||||
</select>
|
||||
</div>
|
||||
{isRemEu && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Provider REM europeo</Label>
|
||||
<Input
|
||||
placeholder="es. docutel, anodet, de-mail"
|
||||
{...register('rem_provider')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isRemEu && (
|
||||
<p className="text-xs text-blue-700 bg-blue-50 rounded p-2">
|
||||
Casella configurata per REM europea (ETSI EN 319 532-4). I messaggi in arrivo
|
||||
con header X-REM-* verranno classificati automaticamente con il parser REM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separatore IMAP */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<h4 className="text-sm font-semibold">Configurazione IMAP (ricezione)</h4>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -309,8 +309,22 @@ function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messa
|
||||
queryFn: () => labelsApi.list(),
|
||||
})
|
||||
|
||||
// Le label tassonomiche del messaggio sono quelle con parent_id != null
|
||||
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
|
||||
// Le label tassonomiche del messaggio: nodi figli (parent_id != null) PIU' i nodi
|
||||
// radice che hanno almeno un figlio nell'albero (identificati come parent_id di
|
||||
// qualche altra label). Questo permette di visualizzare anche gli Ambiti (livello 0)
|
||||
// quando sono assegnati direttamente a un messaggio.
|
||||
const taxonomyIdSet = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
allLabels.forEach((l: LabelResponse) => {
|
||||
if (l.parent_id !== null) {
|
||||
ids.add(l.id) // nodo figlio -> tassonomico
|
||||
ids.add(l.parent_id) // suo genitore radice -> anch'esso tassonomico
|
||||
}
|
||||
})
|
||||
return ids
|
||||
}, [allLabels])
|
||||
|
||||
const taxonomyLabels = messageLabels.filter((l) => taxonomyIdSet.has(l.id))
|
||||
|
||||
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
|
||||
function buildPath(labelId: string): string {
|
||||
|
||||
@@ -37,10 +37,13 @@ import {
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthStore } from '@/store/auth.store'
|
||||
import { usersApi } from '@/api/users.api'
|
||||
import { tenantsApi } from '@/api/tenants.api'
|
||||
import type { TenantResponse } from '@/types/api.types'
|
||||
import {
|
||||
settingsApi,
|
||||
type TenantSettingsResponse,
|
||||
type ArchivalMode,
|
||||
type ConservatoreTestResult,
|
||||
type IndexingStats,
|
||||
type IndexingJobStatus,
|
||||
type ReindexMode,
|
||||
@@ -973,32 +976,53 @@ export function SettingsPage() {
|
||||
const [loadingArchival, setLoadingArchival] = useState(false)
|
||||
const [archivalExpanded, setArchivalExpanded] = useState(false)
|
||||
|
||||
// Selector tenant per super_admin
|
||||
const [tenantList, setTenantList] = useState<TenantResponse[]>([])
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string | undefined>(undefined)
|
||||
|
||||
// Form archiviazione
|
||||
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
|
||||
const [conservatoreId, setConservatoreId] = useState('')
|
||||
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
|
||||
const [conservatoreTenantSlug, setConservatoreTenantSlug] = useState('')
|
||||
const [conservatoreUsername, setConservatoreUsername] = useState('')
|
||||
const [conservatorePassword, setConservatorePassword] = useState('')
|
||||
const [archivalNotes, setArchivalNotes] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [savingArchival, setSavingArchival] = useState(false)
|
||||
const [showProductionConfirm, setShowProductionConfirm] = useState(false)
|
||||
const [testingConnection, setTestingConnection] = useState(false)
|
||||
const [testResult, setTestResult] = useState<ConservatoreTestResult | null>(null)
|
||||
|
||||
/* ── Carica lista tenant (solo super_admin) ── */
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
tenantsApi.list().then(setTenantList).catch(() => {
|
||||
// Silenzioso: la lista e' un extra di comodita'
|
||||
})
|
||||
}
|
||||
}, [isSuperAdmin])
|
||||
|
||||
/* ── Carica impostazioni archiviazione ── */
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
loadArchivalSettings()
|
||||
loadArchivalSettings(selectedTenantId)
|
||||
}
|
||||
}, [isAdmin])
|
||||
}, [isAdmin, selectedTenantId])
|
||||
|
||||
const loadArchivalSettings = async () => {
|
||||
const loadArchivalSettings = async (tenantId?: string) => {
|
||||
setLoadingArchival(true)
|
||||
// Resetta credenziali ogni volta che si cambia tenant
|
||||
setConservatoreUsername('')
|
||||
setConservatorePassword('')
|
||||
setTestResult(null)
|
||||
try {
|
||||
const data = await settingsApi.get()
|
||||
const data = await settingsApi.get(tenantId)
|
||||
setArchivalSettings(data)
|
||||
setArchivalMode(data.archival_mode)
|
||||
setConservatoreId(data.conservatore_id)
|
||||
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
|
||||
setConservatoreTenantSlug(data.conservatore_tenant_slug ?? '')
|
||||
setArchivalNotes(data.archival_notes ?? '')
|
||||
} catch {
|
||||
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
|
||||
@@ -1050,6 +1074,27 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Test connessione conservatore ── */
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await settingsApi.testConservatore(selectedTenantId)
|
||||
setTestResult(result)
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })
|
||||
?.response?.data?.detail
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: msg ?? 'Errore durante il test di connessione',
|
||||
latency_ms: null,
|
||||
provider_info: null,
|
||||
})
|
||||
} finally {
|
||||
setTestingConnection(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cambio modalita' archiviazione ── */
|
||||
const handleModeToggle = (newMode: ArchivalMode) => {
|
||||
if (newMode === 'production' && archivalMode === 'mock') {
|
||||
@@ -1073,21 +1118,24 @@ export function SettingsPage() {
|
||||
archival_mode: archivalMode,
|
||||
conservatore_id: conservatoreId || undefined,
|
||||
conservatore_endpoint: conservatoreEndpoint || undefined,
|
||||
conservatore_tenant_slug: conservatoreTenantSlug || undefined,
|
||||
archival_notes: archivalNotes || undefined,
|
||||
}
|
||||
|
||||
if (conservatoreUsername) payload.conservatore_username = conservatoreUsername
|
||||
if (conservatorePassword) payload.conservatore_password = conservatorePassword
|
||||
|
||||
const updated = await settingsApi.update(payload)
|
||||
const updated = await settingsApi.update(payload, selectedTenantId)
|
||||
setArchivalSettings(updated)
|
||||
setArchivalMode(updated.archival_mode)
|
||||
setConservatoreId(updated.conservatore_id)
|
||||
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
|
||||
setConservatoreTenantSlug(updated.conservatore_tenant_slug ?? '')
|
||||
setArchivalNotes(updated.archival_notes ?? '')
|
||||
setConservatoreUsername('')
|
||||
setConservatorePassword('')
|
||||
setShowProductionConfirm(false)
|
||||
setTestResult(null)
|
||||
|
||||
toast.success(
|
||||
updated.archival_mode === 'production'
|
||||
@@ -1231,6 +1279,41 @@ export function SettingsPage() {
|
||||
|
||||
{archivalExpanded && (
|
||||
<div className="mt-5 space-y-5">
|
||||
|
||||
{/* ── Selector tenant (solo super_admin) ── */}
|
||||
{isSuperAdmin && tenantList.length > 0 && (
|
||||
<div className="rounded-lg bg-purple-50 border border-purple-200 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-3.5 w-3.5 text-purple-600" />
|
||||
<span className="text-xs font-semibold text-purple-800 uppercase tracking-wide">
|
||||
Gestione per tenant (Super Admin)
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
className="w-full rounded-md border border-purple-300 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
value={selectedTenantId ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setSelectedTenantId(val || undefined)
|
||||
setShowProductionConfirm(false)
|
||||
}}
|
||||
>
|
||||
<option value="">— Il tuo tenant (super_admin) —</option>
|
||||
{tenantList.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug}){!t.is_active ? ' — SOSPESO' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTenantId && (
|
||||
<p className="text-xs text-purple-700">
|
||||
Stai modificando le impostazioni del tenant selezionato.
|
||||
Le modifiche vengono salvate separatamente per ogni tenant.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingArchival ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
Caricamento impostazioni...
|
||||
@@ -1334,7 +1417,21 @@ export function SettingsPage() {
|
||||
id="conservatore_endpoint"
|
||||
value={conservatoreEndpoint}
|
||||
onChange={(e) => setConservatoreEndpoint(e.target.value)}
|
||||
placeholder="https://conservatore.provider.it/api/v1"
|
||||
placeholder="https://api.aeterna.idrainformatica.it"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="conservatore_tenant_slug">
|
||||
Tenant Slug
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">(es. Aeterna: slug organizzazione)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="conservatore_tenant_slug"
|
||||
value={conservatoreTenantSlug}
|
||||
onChange={(e) => setConservatoreTenantSlug(e.target.value)}
|
||||
placeholder="es. pechub"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1398,6 +1495,7 @@ export function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Riepilogo configurazione salvata ── */}
|
||||
{archivalSettings && (
|
||||
<div className={`rounded-lg p-3 text-xs border ${
|
||||
archivalSettings.archival_mode === 'production'
|
||||
@@ -1411,6 +1509,9 @@ export function SettingsPage() {
|
||||
{archivalSettings.conservatore_endpoint && (
|
||||
<li>Endpoint: <strong className="font-mono text-xs break-all">{archivalSettings.conservatore_endpoint}</strong></li>
|
||||
)}
|
||||
{archivalSettings.conservatore_tenant_slug && (
|
||||
<li>Tenant Slug: <strong>{archivalSettings.conservatore_tenant_slug}</strong></li>
|
||||
)}
|
||||
<li>Credenziali: {
|
||||
archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
|
||||
? <strong className="text-green-700">Configurate</strong>
|
||||
@@ -1420,7 +1521,51 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
{/* ── Risultato test connessione ── */}
|
||||
{testResult && (
|
||||
<div className={`rounded-lg p-3 text-xs border flex items-start gap-2 ${
|
||||
testResult.success
|
||||
? 'bg-green-50 border-green-200 text-green-800'
|
||||
: 'bg-red-50 border-red-200 text-red-800'
|
||||
}`}>
|
||||
{testResult.success
|
||||
? <CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
: <AlertTriangle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-medium">{testResult.success ? 'Connessione riuscita' : 'Connessione fallita'}</p>
|
||||
<p>{testResult.message}</p>
|
||||
{testResult.latency_ms !== null && (
|
||||
<p className="text-gray-500">Latenza: {testResult.latency_ms} ms</p>
|
||||
)}
|
||||
{testResult.provider_info && testResult.success && (
|
||||
<div className="mt-1 text-gray-600 space-y-0.5">
|
||||
{Object.entries(testResult.provider_info).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<span className="text-gray-400">{k}: </span>
|
||||
<span>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Azioni ── */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection || archivalMode !== 'production'}
|
||||
className="flex items-center gap-1.5 text-blue-700 border-blue-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
{testingConnection
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
{testingConnection ? 'Test in corso...' : 'Testa connessione'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveArchival}
|
||||
disabled={savingArchival}
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface UserUpdateRequest {
|
||||
|
||||
export type MailboxStatus = 'active' | 'paused' | 'error' | 'deleted'
|
||||
|
||||
export type MailboxProtocol = 'pec_it' | 'rem_eu'
|
||||
|
||||
export interface MailboxResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
@@ -114,6 +116,9 @@ export interface MailboxResponse {
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_use_tls: boolean
|
||||
// Protocollo (Feature N8 – REM europea)
|
||||
protocol_type: MailboxProtocol
|
||||
rem_provider: string | null
|
||||
status: MailboxStatus
|
||||
last_sync_at: string | null
|
||||
last_sync_uid: number | null
|
||||
@@ -135,6 +140,9 @@ export interface MailboxCreateRequest {
|
||||
email_address: string
|
||||
display_name?: string
|
||||
provider?: string
|
||||
// Protocollo (Feature N8 – REM europea)
|
||||
protocol_type?: MailboxProtocol
|
||||
rem_provider?: string
|
||||
imap_host: string
|
||||
imap_port: number
|
||||
imap_user: string
|
||||
@@ -151,6 +159,9 @@ export interface MailboxUpdateRequest {
|
||||
display_name?: string
|
||||
provider?: string
|
||||
status?: 'active' | 'paused'
|
||||
// Protocollo (Feature N8)
|
||||
protocol_type?: MailboxProtocol
|
||||
rem_provider?: string | null
|
||||
imap_host?: string
|
||||
imap_port?: number
|
||||
imap_user?: string
|
||||
|
||||
@@ -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)
|
||||
|
||||
+48
-12
@@ -39,7 +39,8 @@ from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
|
||||
from app.jobs.index_message import index_message
|
||||
from app.models import Attachment, Mailbox, Message
|
||||
from app.parsers.eml_parser import parse_eml
|
||||
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
|
||||
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message, detect_protocol
|
||||
from app.parsers.rem_parser import classify_rem_message
|
||||
from app.storage.minio_client import upload_attachment, upload_eml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -543,15 +544,47 @@ async def _save_message(
|
||||
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
|
||||
return False
|
||||
|
||||
# ── Classificazione PEC da header (veloce, senza body) ───────────────────
|
||||
# ── Classificazione tipo messaggio da header (veloce, senza body) ────────
|
||||
# La classificazione avviene PRIMA del parsing completo perche' il parser
|
||||
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
|
||||
# il body_text (testo della ricevuta) con il contenuto di postacert.eml.
|
||||
#
|
||||
# Strategia dual-protocol (Feature N8 – REM europea):
|
||||
# 1. Rileva automaticamente il protocollo dagli header del messaggio.
|
||||
# 2. Se header X-REM-* presenti: usa classify_rem_message (ETSI EN 319 532-4)
|
||||
# 3. Se header PEC italiani (X-Ricevuta/X-TipoRicevuta): usa classify_pec_message
|
||||
# 4. Default fallback: PEC italiana
|
||||
#
|
||||
# Il protocollo configurato sulla casella (mailbox.protocol_type) viene
|
||||
# usato come hint, ma il rilevamento automatico degli header ha priorita'.
|
||||
quick_msg = email.message_from_bytes(raw_eml)
|
||||
|
||||
mailbox_protocol = getattr(mailbox, "protocol_type", "pec_it") or "pec_it"
|
||||
detected_protocol = detect_protocol(quick_msg)
|
||||
# Il rilevamento automatico ha priorita': una casella PEC-IT che riceve un
|
||||
# messaggio REM da partner europeo viene classificata correttamente.
|
||||
_protocol_type = detected_protocol if detected_protocol == "rem_eu" else mailbox_protocol
|
||||
|
||||
if _protocol_type == "rem_eu":
|
||||
rem_class = classify_rem_message(quick_msg)
|
||||
_pec_type = rem_class.pec_type
|
||||
_is_receipt = rem_class.is_receipt
|
||||
_riferimento_message_id = rem_class.riferimento_message_id
|
||||
_rem_evidence_type = rem_class.rem_evidence_type
|
||||
logger.debug(
|
||||
f"[{mailbox.email_address}] REM UID={uid}: "
|
||||
f"evidence={_rem_evidence_type!r} → pec_type={_pec_type!r}"
|
||||
)
|
||||
else:
|
||||
pec_class = classify_pec_message(quick_msg)
|
||||
_pec_type = pec_class.pec_type
|
||||
_is_receipt = pec_class.is_receipt
|
||||
_riferimento_message_id = pec_class.riferimento_message_id
|
||||
_rem_evidence_type = None
|
||||
_protocol_type = "pec_it"
|
||||
|
||||
# ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
|
||||
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
|
||||
parsed = parse_eml(raw_eml, is_receipt=_is_receipt)
|
||||
received_at = datetime.now(UTC)
|
||||
|
||||
# ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─
|
||||
@@ -605,18 +638,18 @@ async def _save_message(
|
||||
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
|
||||
parent_message_id: uuid.UUID | None = None
|
||||
|
||||
if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id:
|
||||
if direction == "inbound" and _is_receipt and _riferimento_message_id:
|
||||
try:
|
||||
parent_message_id = await _apply_outbound_state_machine(
|
||||
riferimento_message_id=pec_class.riferimento_message_id,
|
||||
pec_type=pec_class.pec_type,
|
||||
riferimento_message_id=_riferimento_message_id,
|
||||
pec_type=_pec_type,
|
||||
tenant_id=mailbox.tenant_id,
|
||||
db=db,
|
||||
)
|
||||
except Exception as bind_err:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato "
|
||||
f"outbound per ricevuta UID={uid} tipo={pec_class.pec_type!r}: {bind_err}",
|
||||
f"outbound per ricevuta UID={uid} tipo={_pec_type!r}: {bind_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Non interrompere il salvataggio della ricevuta: il record viene
|
||||
@@ -648,7 +681,9 @@ async def _save_message(
|
||||
imap_folder=imap_folder,
|
||||
direction=direction,
|
||||
state=state,
|
||||
pec_type=pec_class.pec_type,
|
||||
pec_type=_pec_type,
|
||||
protocol_type=_protocol_type,
|
||||
rem_evidence_type=_rem_evidence_type,
|
||||
subject=parsed.subject,
|
||||
from_address=parsed.from_address,
|
||||
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
|
||||
@@ -687,7 +722,7 @@ async def _save_message(
|
||||
"from_address": message.from_address or "",
|
||||
"pec_type": message.pec_type,
|
||||
"direction": direction,
|
||||
"is_receipt": pec_class.is_receipt,
|
||||
"is_receipt": _is_receipt,
|
||||
"received_at": received_at.isoformat(),
|
||||
}
|
||||
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
|
||||
@@ -696,7 +731,7 @@ async def _save_message(
|
||||
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} folder={imap_folder!r} "
|
||||
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
|
||||
f"direction={direction!r} pec_type={_pec_type!r} protocol={_protocol_type!r} "
|
||||
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
||||
)
|
||||
|
||||
@@ -719,7 +754,8 @@ async def _save_message(
|
||||
|
||||
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
|
||||
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
|
||||
if direction == "inbound" and pec_class.pec_type == "posta_certificata":
|
||||
# Valido anche per messaggi REM (REMDispatch e' equivalente a posta_certificata).
|
||||
if direction == "inbound" and _pec_type == "posta_certificata":
|
||||
try:
|
||||
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
|
||||
except Exception as e:
|
||||
@@ -728,7 +764,7 @@ async def _save_message(
|
||||
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
|
||||
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
||||
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
|
||||
if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
|
||||
if direction == "inbound" and _pec_type == "posta_certificata" and message.from_address:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
await db.execute(
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
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
|
||||
+13
-3
@@ -19,13 +19,14 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from arq import run_worker
|
||||
from arq import cron, run_worker
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
from app.imap.pool import MailboxPool
|
||||
from app.jobs.apply_routing_rules import apply_routing_rules
|
||||
from app.jobs.dispatch_notification import dispatch_notification
|
||||
from app.jobs.run_conservation import run_conservation
|
||||
from app.jobs.send_pec import send_pec
|
||||
from app.jobs.sync_mailbox import sync_mailbox
|
||||
from app.smtp.receipt_watcher import watch_receipt
|
||||
@@ -133,9 +134,17 @@ def _parse_redis_settings() -> RedisSettings:
|
||||
class WorkerSettings:
|
||||
"""Configurazione del worker arq."""
|
||||
|
||||
# Funzioni/job registrati
|
||||
# Funzioni/job registrati (code-driven, on-demand)
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
|
||||
|
||||
# Job schedulati (cron)
|
||||
# run_conservation: ogni giorno alle 16:00 UTC = 18:00 ora Italia (CEST, UTC+2)
|
||||
# Nota: arq usa sempre UTC per i cron. In orario solare (CET, UTC+1) il job
|
||||
# viene eseguito alle 17:00 ora Italia – differenza accettabile.
|
||||
cron_jobs = [
|
||||
cron(run_conservation, hour=16, minute=0),
|
||||
]
|
||||
|
||||
# Callbacks lifecycle
|
||||
on_startup = on_startup
|
||||
on_shutdown = on_shutdown
|
||||
@@ -148,7 +157,8 @@ class WorkerSettings:
|
||||
|
||||
# Timeout per ogni job (secondi)
|
||||
# send_pec può richiedere più tempo su SMTP lenti
|
||||
job_timeout = 120
|
||||
# run_conservation può richiedere più tempo per batch grandi
|
||||
job_timeout = 300
|
||||
|
||||
# Retry automatico in caso di errore
|
||||
max_tries = 3
|
||||
|
||||
@@ -76,6 +76,10 @@ class Mailbox(Base):
|
||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Protocollo (Feature N8 – REM europea)
|
||||
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
|
||||
rem_provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
@@ -121,6 +125,10 @@ class Message(Base):
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Protocollo e REM europea (Feature N8)
|
||||
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
|
||||
rem_evidence_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -286,6 +294,32 @@ class NotificationRule(Base):
|
||||
)
|
||||
|
||||
|
||||
class TenantSettings(Base):
|
||||
"""
|
||||
Configurazione per-tenant – archiviazione sostitutiva.
|
||||
Replica del modello backend, letta dal worker per recuperare le credenziali
|
||||
del conservatore (decifrate a runtime da decrypt_credential).
|
||||
"""
|
||||
|
||||
__tablename__ = "tenant_settings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
archival_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mock")
|
||||
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False, default="mock")
|
||||
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conservatore_tenant_slug: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
"""
|
||||
Log di ogni tentativo di notifica con retry e circuit breaker.
|
||||
|
||||
@@ -171,12 +171,28 @@ def parse_eml(raw_bytes: bytes, is_receipt: bool = False) -> ParsedEmail:
|
||||
# ─── Helper privati ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Nomi file usati dall'infrastruttura PEC (non allegati utente)
|
||||
# Nomi file usati dall'infrastruttura PEC italiana (non allegati utente)
|
||||
_PEC_SYSTEM_FILENAMES = frozenset({
|
||||
# ── PEC italiana (DM 2 novembre 2005) ─────────────────────────────────────
|
||||
"daticert.xml",
|
||||
"postacert.eml",
|
||||
"smime.p7s",
|
||||
"smime.p7m",
|
||||
# ── REM europea (ETSI EN 319 532-4) ──────────────────────────────────────
|
||||
"remevidence.xml",
|
||||
"rem-evidence.xml",
|
||||
"rem_evidence.xml",
|
||||
"remreceipt.xml",
|
||||
"rem-receipt.xml",
|
||||
"remdispatch.xml",
|
||||
"rem-dispatch.xml",
|
||||
"remdelivery.xml",
|
||||
"rem-delivery.xml",
|
||||
"remdispatch.eml",
|
||||
"rem-dispatch.eml",
|
||||
"remsignature.p7s",
|
||||
"rem-signature.p7s",
|
||||
"signed-rem-dispatch.p7m",
|
||||
})
|
||||
|
||||
# Content-type usati dall'infrastruttura PEC
|
||||
|
||||
@@ -170,6 +170,30 @@ def get_state_transition(pec_type: str) -> str | None:
|
||||
return _RECEIPT_TO_STATE.get(pec_type)
|
||||
|
||||
|
||||
def detect_protocol(msg: email.message.Message) -> str:
|
||||
"""
|
||||
Determina il protocollo di un messaggio in arrivo.
|
||||
|
||||
Logica di rilevamento automatico:
|
||||
- Se il messaggio contiene almeno un header X-REM-*, il protocollo e' REM europea
|
||||
- Altrimenti e' PEC italiana (default)
|
||||
|
||||
Questo permette al worker di usare il parser corretto (classify_rem_message vs
|
||||
classify_pec_message) anche per caselle configurate come 'pec_it' che potrebbero
|
||||
ricevere messaggi REM da partner europei (caso edge).
|
||||
|
||||
Args:
|
||||
msg: oggetto email.message.Message gia' parsato dagli header
|
||||
|
||||
Returns:
|
||||
'rem_eu' se header X-REM-* rilevati, 'pec_it' altrimenti.
|
||||
"""
|
||||
for header_name in msg.keys():
|
||||
if header_name.upper().startswith("X-REM-"):
|
||||
return "rem_eu"
|
||||
return "pec_it"
|
||||
|
||||
|
||||
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
|
||||
"""
|
||||
Applica la state machine al messaggio outbound.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Parser specifico per messaggi REM (Registered Electronic Mail europea).
|
||||
|
||||
Standard di riferimento: ETSI EN 319 532-4
|
||||
"Electronic Registered Delivery Services – REMDispatch and Evidence"
|
||||
|
||||
La REM europea e' il protocollo transfrontaliero equivalente alla PEC italiana.
|
||||
Usa header X-REM-* invece degli header X-Ricevuta / X-TipoRicevuta della PEC italiana.
|
||||
|
||||
Header REM principali letti:
|
||||
X-REM-Evidence-Type → tipo evidenza (SubmissionAcceptance, DeliveryInformation, ecc.)
|
||||
X-REM-Type → alias usato da alcuni provider (stesso significato)
|
||||
X-REM-Orig-Message-Id → Message-ID del messaggio originale (correlazione)
|
||||
X-REM-Message-Id → alias per il Message-ID di riferimento
|
||||
X-REM-Delivery-Status → success/failure per DeliveryInformation
|
||||
X-REM-Timestamp → timestamp ISO 8601 (informativo)
|
||||
X-REM-Provider → nome provider REM (informativo)
|
||||
|
||||
Strategia di mapping:
|
||||
I tipi evidenza REM vengono mappati agli stessi enum DB della PEC italiana
|
||||
(pec_type enum: posta_certificata, accettazione, presa_in_carico, ecc.)
|
||||
senza aggiungere nuovi tipi DB. Il valore originale viene conservato in
|
||||
rem_evidence_type per trasparenza.
|
||||
"""
|
||||
|
||||
import email.message
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Mapping evidence type REM → enum DB (riusa enum PEC italiana) ───────────
|
||||
#
|
||||
# ETSI EN 319 532-4 evidence types e loro equivalenti PEC italiani:
|
||||
#
|
||||
# REMDispatch → posta_certificata (il messaggio inviato)
|
||||
# SubmissionAcceptance → accettazione (accettato dal provider mittente)
|
||||
# RelayAcceptance → presa_in_carico (accettato per inoltro al provider destinatario)
|
||||
# DeliveryInformation → avvenuta_consegna (consegnato, se X-REM-Delivery-Status = success)
|
||||
# → errore_consegna (se X-REM-Delivery-Status = failed/error)
|
||||
# DeliveryNonAcceptance → non_accettazione (consegna rifiutata dal destinatario)
|
||||
# DeliveryExpiry → mancata_consegna (scadenza senza consegna)
|
||||
# RetrievalNonAcceptance → mancata_consegna (non recuperato dal destinatario)
|
||||
# ReceivedByNonREM → presa_in_carico (consegnato a sistema non-REM)
|
||||
# SubmissionRejection → non_accettazione (rifiutato dal provider mittente)
|
||||
# RelayRejection → non_accettazione (rifiutato durante relay)
|
||||
|
||||
_REM_TYPE_MAP: dict[str, str] = {
|
||||
# ── Nomi standard ETSI (PascalCase) ──────────────────────────────────────
|
||||
"remdispatch": "posta_certificata",
|
||||
"submissionacceptance": "accettazione",
|
||||
"relayacceptance": "presa_in_carico",
|
||||
"deliveryinformation": "avvenuta_consegna", # rifinito con X-REM-Delivery-Status
|
||||
"deliverynonacceptance": "non_accettazione",
|
||||
"deliveryexpiry": "mancata_consegna",
|
||||
"retrievalnonacceptance": "mancata_consegna",
|
||||
"receivedbynonrem": "presa_in_carico",
|
||||
"submissionrejection": "non_accettazione",
|
||||
"relayrejection": "non_accettazione",
|
||||
|
||||
# ── Varianti con trattini (usate da alcuni provider europei) ─────────────
|
||||
"rem-dispatch": "posta_certificata",
|
||||
"submission-acceptance": "accettazione",
|
||||
"relay-acceptance": "presa_in_carico",
|
||||
"delivery-information": "avvenuta_consegna",
|
||||
"delivery-non-acceptance": "non_accettazione",
|
||||
"delivery-expiry": "mancata_consegna",
|
||||
"retrieval-non-acceptance": "mancata_consegna",
|
||||
"received-by-non-rem": "presa_in_carico",
|
||||
"submission-rejection": "non_accettazione",
|
||||
"relay-rejection": "non_accettazione",
|
||||
|
||||
# ── Varianti con underscore ───────────────────────────────────────────────
|
||||
"rem_dispatch": "posta_certificata",
|
||||
"submission_acceptance": "accettazione",
|
||||
"relay_acceptance": "presa_in_carico",
|
||||
"delivery_information": "avvenuta_consegna",
|
||||
"delivery_non_acceptance": "non_accettazione",
|
||||
"delivery_expiry": "mancata_consegna",
|
||||
"retrieval_non_acceptance": "mancata_consegna",
|
||||
"received_by_non_rem": "presa_in_carico",
|
||||
"submission_rejection": "non_accettazione",
|
||||
"relay_rejection": "non_accettazione",
|
||||
}
|
||||
|
||||
# Valori X-REM-Delivery-Status che indicano fallimento della consegna
|
||||
# (usati per distinguere DeliveryInformation success da failure)
|
||||
_DELIVERY_FAILURE_STATUSES: frozenset[str] = frozenset({
|
||||
"failed", "failure", "error", "rejected", "undeliverable",
|
||||
"not-delivered", "not_delivered", "ko",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemClassification:
|
||||
"""Risultato della classificazione di un messaggio REM."""
|
||||
|
||||
pec_type: str # valore enum DB (riusa enum PEC italiana)
|
||||
rem_evidence_type: str | None # valore raw dell'header X-REM-Evidence-Type
|
||||
riferimento_message_id: str | None # X-REM-Orig-Message-Id (correlazione outbound)
|
||||
is_receipt: bool # True se e' un'evidenza (non il messaggio originale)
|
||||
|
||||
|
||||
def classify_rem_message(msg: email.message.Message) -> RemClassification:
|
||||
"""
|
||||
Classifica il tipo di messaggio REM analizzando gli header X-REM-*.
|
||||
|
||||
Header letti (in ordine di priorita'):
|
||||
1. X-REM-Evidence-Type (tipo evidenza principale, ETSI EN 319 532-4)
|
||||
2. X-REM-Type (alias usato da alcuni provider)
|
||||
3. X-REM-Orig-Message-Id (correlazione con il messaggio originale)
|
||||
4. X-REM-Message-Id (alias per il Message-ID di riferimento)
|
||||
5. X-REM-Delivery-Status (per DeliveryInformation: distingue success/failure)
|
||||
|
||||
Se X-REM-Evidence-Type non e' presente, il messaggio viene classificato
|
||||
come 'posta_certificata' (REMDispatch: il messaggio originale).
|
||||
|
||||
Returns:
|
||||
RemClassification con pec_type mappato all'enum DB esistente.
|
||||
"""
|
||||
# Header principali
|
||||
raw_evidence = _clean(
|
||||
msg.get("X-REM-Evidence-Type") or msg.get("X-REM-Type")
|
||||
)
|
||||
x_ref = _clean(
|
||||
msg.get("X-REM-Orig-Message-Id") or msg.get("X-REM-Message-Id")
|
||||
)
|
||||
delivery_status = _clean(msg.get("X-REM-Delivery-Status"))
|
||||
|
||||
# Normalizza la chiave per il lookup nella mappa
|
||||
evidence_key = (raw_evidence or "").lower().strip()
|
||||
pec_type = _REM_TYPE_MAP.get(evidence_key, "posta_certificata")
|
||||
|
||||
# Gestione speciale DeliveryInformation:
|
||||
# Se X-REM-Delivery-Status indica un fallimento, ri-mappa su errore_consegna
|
||||
if pec_type == "avvenuta_consegna" and delivery_status:
|
||||
if delivery_status.lower().strip() in _DELIVERY_FAILURE_STATUSES:
|
||||
pec_type = "errore_consegna"
|
||||
logger.debug(
|
||||
f"REM DeliveryInformation con status={delivery_status!r}: "
|
||||
f"rimappato a errore_consegna"
|
||||
)
|
||||
|
||||
is_receipt = pec_type != "posta_certificata"
|
||||
|
||||
logger.debug(
|
||||
f"REM classify: evidence={raw_evidence!r} → pec_type={pec_type!r} "
|
||||
f"is_receipt={is_receipt} ref={x_ref!r}"
|
||||
)
|
||||
|
||||
return RemClassification(
|
||||
pec_type=pec_type,
|
||||
rem_evidence_type=raw_evidence,
|
||||
riferimento_message_id=x_ref,
|
||||
is_receipt=is_receipt,
|
||||
)
|
||||
|
||||
|
||||
def detect_rem_headers(msg: email.message.Message) -> bool:
|
||||
"""
|
||||
Verifica se un messaggio contiene header REM (X-REM-*).
|
||||
|
||||
Usato da detect_protocol() in pec_parser.py per determinare automaticamente
|
||||
se un messaggio e' REM europea o PEC italiana durante la sincronizzazione.
|
||||
|
||||
Returns:
|
||||
True se almeno un header X-REM-* e' presente, False altrimenti.
|
||||
"""
|
||||
for header_name in msg.keys():
|
||||
if header_name.upper().startswith("X-REM-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ─── Verifica file di sistema REM ─────────────────────────────────────────────
|
||||
|
||||
# Nomi file usati dall'infrastruttura REM (non allegati utente)
|
||||
# Analoghi a daticert.xml / postacert.eml della PEC italiana
|
||||
REM_SYSTEM_FILENAMES: frozenset[str] = frozenset({
|
||||
"remevidence.xml",
|
||||
"rem-evidence.xml",
|
||||
"rem_evidence.xml",
|
||||
"remreceipt.xml",
|
||||
"rem-receipt.xml",
|
||||
"remdispatch.xml",
|
||||
"rem-dispatch.xml",
|
||||
"remdelivery.xml",
|
||||
"rem-delivery.xml",
|
||||
"remdispatch.eml",
|
||||
"rem-dispatch.eml",
|
||||
"remsignature.p7s",
|
||||
"rem-signature.p7s",
|
||||
"signed-rem-dispatch.p7m",
|
||||
})
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str | None:
|
||||
"""Pulisce e normalizza il valore di un header REM."""
|
||||
if not value:
|
||||
return None
|
||||
return value.strip()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Modulo sicurezza worker – decifratura credenziali AES-256-GCM.
|
||||
|
||||
Replica solo le funzioni necessarie al worker (decrypt_credential).
|
||||
La chiave di cifratura viene letta dalla variabile d'ambiente ENCRYPTION_KEY
|
||||
tramite WorkerSettings (stesso valore del backend).
|
||||
|
||||
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def decrypt_credential(encrypted: str) -> str:
|
||||
"""
|
||||
Decifra una stringa cifrata con AES-256-GCM.
|
||||
|
||||
Compatibile con encrypt_credential() del backend (stesso formato).
|
||||
Solleva ValueError se la decifratura fallisce.
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(encrypted.encode("ascii"))
|
||||
nonce = raw[:12]
|
||||
ciphertext_with_tag = raw[12:]
|
||||
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decifratura credenziale fallita: {e}") from e
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
Script di test trasmissione verso Aeterna (archiviazione certificata).
|
||||
|
||||
Questo script è STANDALONE: si connette direttamente al DB PecHub,
|
||||
trova i messaggi con is_pending_conservation=True, scarica i loro EML
|
||||
da MinIO, costruisce pacchetti SIP BagIt e li invia ad Aeterna.
|
||||
|
||||
Utilizzo (eseguire dal server dentro il container worker o direttamente):
|
||||
|
||||
# Sul server, dentro il container worker:
|
||||
docker exec -it pechub-worker-1 python /app/scripts/test_aeterna_transmission.py
|
||||
|
||||
# Con credenziali personalizzate:
|
||||
AETERNA_USERNAME=xxx AETERNA_PASSWORD=yyy \
|
||||
python worker/scripts/test_aeterna_transmission.py
|
||||
|
||||
Variabili d'ambiente accettate (sovrascrivono i default):
|
||||
AETERNA_ENDPOINT Default: https://api.aeterna.idrainformatica.it
|
||||
AETERNA_USERNAME Default: matteo@idrainformatica.it
|
||||
AETERNA_PASSWORD Default: letto da .env
|
||||
AETERNA_TENANT_SLUG Default: pechub
|
||||
DATABASE_URL Default: letta da .env del worker
|
||||
|
||||
Output:
|
||||
Per ogni messaggio: stato trasmissione, versamento_id Aeterna, latenza.
|
||||
Al termine: aggiorna is_conserved=True nel DB se l'ingest e' riuscito.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Setup path per importare dal worker ─────────────────────────────────────
|
||||
# Se eseguito da fuori il container, aggiungi il path del worker
|
||||
worker_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(worker_dir))
|
||||
|
||||
# ─── Configurazione Aeterna ───────────────────────────────────────────────────
|
||||
|
||||
AETERNA_ENDPOINT = os.getenv("AETERNA_ENDPOINT", "https://api.aeterna.idrainformatica.it")
|
||||
AETERNA_USERNAME = os.getenv("AETERNA_USERNAME", "matteo@idrainformatica.it")
|
||||
AETERNA_PASSWORD = os.getenv("AETERNA_PASSWORD", "Ma212718!")
|
||||
AETERNA_TENANT_SLUG = os.getenv("AETERNA_TENANT_SLUG", "pechub")
|
||||
|
||||
# ─── Funzioni helper ──────────────────────────────────────────────────────────
|
||||
|
||||
def log(msg: str) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}")
|
||||
|
||||
|
||||
def log_section(title: str) -> None:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {title}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
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 BagIt RFC 8493 in memoria (ZIP)."""
|
||||
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()
|
||||
|
||||
bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
|
||||
|
||||
bag_info_lines = [
|
||||
"Bag-Software-Agent: PecHub Archival Module (test script)",
|
||||
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]}")
|
||||
bag_info_txt = "\n".join(bag_info_lines) + "\n"
|
||||
|
||||
manifest_txt = f"{eml_sha256} {data_path}\n"
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ─── Client Aeterna (inline, senza dipendenze worker) ────────────────────────
|
||||
|
||||
class AeternaTestClient:
|
||||
"""Client minimale per il test di trasmissione."""
|
||||
|
||||
def __init__(self, endpoint: str, username: str, password: str, tenant_slug: str):
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.tenant_slug = tenant_slug
|
||||
self._token: str | None = None
|
||||
self._token_expires_at: float = 0.0
|
||||
|
||||
async def login(self) -> str:
|
||||
import httpx
|
||||
log(f" Autenticazione su Aeterna ({self.endpoint}) ...")
|
||||
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,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Login fallito HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
data = resp.json()
|
||||
self._token = data["access_token"]
|
||||
expires_in = int(data.get("expires_in", 3600))
|
||||
self._token_expires_at = time.monotonic() + expires_in - 60
|
||||
user_email = data.get("user", {}).get("email", "?")
|
||||
log(f" Login riuscito come: {user_email}")
|
||||
return self._token
|
||||
|
||||
async def get_token(self) -> str:
|
||||
if self._token and time.monotonic() < self._token_expires_at:
|
||||
return self._token
|
||||
return await self.login()
|
||||
|
||||
async def upload_sip(
|
||||
self,
|
||||
zip_bytes: bytes,
|
||||
zip_filename: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
import httpx
|
||||
token = await self.get_token()
|
||||
log(f" Upload SIP '{zip_filename}' ({len(zip_bytes):,} bytes) ...")
|
||||
t_start = time.monotonic()
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(
|
||||
f"{self.endpoint}/api/v1/ingest/upload",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
files={"file": (zip_filename, zip_bytes, "application/zip")},
|
||||
data={
|
||||
"title": title[:500],
|
||||
"description": description[:500],
|
||||
"creator": "PecHub Test Script",
|
||||
},
|
||||
)
|
||||
|
||||
latency_ms = int((time.monotonic() - t_start) * 1000)
|
||||
|
||||
if resp.status_code in (200, 201, 202):
|
||||
data = resp.json()
|
||||
log(f" Upload OK in {latency_ms}ms – package_id: {data.get('package_id')}")
|
||||
return {"success": True, "latency_ms": latency_ms, **data}
|
||||
else:
|
||||
log(f" Upload FALLITO HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": latency_ms,
|
||||
"error": resp.text[:300],
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
|
||||
async def poll_status(self, package_id: str, max_polls: int = 10, interval: float = 3.0) -> dict:
|
||||
import httpx
|
||||
token = await self.get_token()
|
||||
log(f" Polling status package_id={package_id} (max {max_polls} tentativi) ...")
|
||||
|
||||
for i in range(max_polls):
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self.endpoint}/api/v1/ingest/{package_id}/status",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
status = data.get("status", "UNKNOWN").upper()
|
||||
stage = data.get("pipeline_stage", "")
|
||||
pct = data.get("progress_pct", 0)
|
||||
log(f" [{i+1}/{max_polls}] status={status} stage={stage} progress={pct}%")
|
||||
|
||||
if status in ("ACTIVE", "FAILED", "REJECTED"):
|
||||
return {"success": status == "ACTIVE", "final_status": status, **data}
|
||||
|
||||
if i < max_polls - 1:
|
||||
await asyncio.sleep(interval)
|
||||
else:
|
||||
log(f" Polling error HTTP {resp.status_code}")
|
||||
break
|
||||
|
||||
log(" Polling completato (stato non finale raggiunto, processo ancora in corso)")
|
||||
return {"success": None, "message": "polling esaurito"}
|
||||
|
||||
|
||||
# ─── Recupero messaggi da DB e MinIO ─────────────────────────────────────────
|
||||
|
||||
async def get_pending_conservation_messages() -> list[dict]:
|
||||
"""
|
||||
Recupera i messaggi con is_pending_conservation=True dal DB PecHub.
|
||||
Restituisce una lista di dict con i campi rilevanti.
|
||||
"""
|
||||
try:
|
||||
import asyncpg # type: ignore[import]
|
||||
except ImportError:
|
||||
log("asyncpg non installato. Uso psycopg2 come fallback...")
|
||||
return await get_messages_via_env()
|
||||
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
if not db_url:
|
||||
log("DATABASE_URL non impostata. Tento connessione locale...")
|
||||
db_url = "postgresql://pechub:pechub@localhost:5432/pechub"
|
||||
|
||||
# asyncpg vuole postgresql:// non postgres:// e senza +asyncpg driver specifier
|
||||
db_url = db_url.replace("postgres://", "postgresql://")
|
||||
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(db_url)
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
m.id,
|
||||
m.tenant_id,
|
||||
m.subject,
|
||||
m.from_address,
|
||||
m.to_addresses,
|
||||
m.received_at,
|
||||
m.raw_eml_path,
|
||||
m.is_pending_conservation,
|
||||
m.is_conserved
|
||||
FROM messages m
|
||||
WHERE m.is_pending_conservation = TRUE
|
||||
AND m.is_conserved = FALSE
|
||||
ORDER BY m.received_at DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
await conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
except Exception as e:
|
||||
log(f"Errore connessione DB: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_messages_via_env() -> list[dict]:
|
||||
"""Fallback: usa variabili d'ambiente per costruire messaggi di test."""
|
||||
log("Uso messaggi di test hardcoded (DB non disponibile)")
|
||||
return []
|
||||
|
||||
|
||||
async def download_eml_from_minio(raw_eml_path: str) -> bytes | None:
|
||||
"""
|
||||
Scarica il file EML da MinIO usando il path memorizzato nel DB.
|
||||
"""
|
||||
try:
|
||||
from app.storage.minio_client import get_minio_client
|
||||
client = await get_minio_client()
|
||||
bucket = os.getenv("MINIO_BUCKET", "pechub")
|
||||
response = await asyncio.to_thread(
|
||||
client.get_object, bucket, raw_eml_path
|
||||
)
|
||||
data = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
return data
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log(f" Errore download MinIO ({raw_eml_path}): {e}")
|
||||
|
||||
# Fallback: prova con boto3/minio direttamente
|
||||
try:
|
||||
import minio # type: ignore[import]
|
||||
endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
||||
access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||
secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||
bucket = os.getenv("MINIO_BUCKET", "pechub")
|
||||
|
||||
client = minio.Minio(
|
||||
endpoint,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
secure=endpoint.startswith("https"),
|
||||
)
|
||||
response = client.get_object(bucket, raw_eml_path)
|
||||
data = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
return data
|
||||
except Exception as e:
|
||||
log(f" Errore download MinIO (fallback): {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def mark_message_conserved(message_id: str, versamento_id: str) -> None:
|
||||
"""Aggiorna il messaggio nel DB come conservato."""
|
||||
try:
|
||||
import asyncpg # type: ignore[import]
|
||||
except ImportError:
|
||||
log(f" [skip] asyncpg non disponibile: impossibile aggiornare is_conserved per {message_id}")
|
||||
return
|
||||
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://pechub:pechub@localhost:5432/pechub")
|
||||
db_url = db_url.replace("postgres://", "postgresql://")
|
||||
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(db_url)
|
||||
await conn.execute("""
|
||||
UPDATE messages
|
||||
SET is_conserved = TRUE,
|
||||
conserved_at = NOW()
|
||||
WHERE id = $1
|
||||
""", uuid.UUID(message_id))
|
||||
await conn.close()
|
||||
log(f" DB aggiornato: is_conserved=TRUE per message_id={message_id}")
|
||||
except Exception as e:
|
||||
log(f" Errore aggiornamento DB per {message_id}: {e}")
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def main() -> None:
|
||||
log_section("TEST TRASMISSIONE AETERNA – PecHub")
|
||||
log(f"Endpoint: {AETERNA_ENDPOINT}")
|
||||
log(f"Username: {AETERNA_USERNAME}")
|
||||
log(f"Tenant slug: {AETERNA_TENANT_SLUG}")
|
||||
log(f"Timestamp: {datetime.now().isoformat()}")
|
||||
|
||||
# 1. Connessione ad Aeterna
|
||||
log_section("1. AUTENTICAZIONE AETERNA")
|
||||
client = AeternaTestClient(
|
||||
endpoint=AETERNA_ENDPOINT,
|
||||
username=AETERNA_USERNAME,
|
||||
password=AETERNA_PASSWORD,
|
||||
tenant_slug=AETERNA_TENANT_SLUG,
|
||||
)
|
||||
try:
|
||||
await client.login()
|
||||
except Exception as e:
|
||||
log(f"ERRORE FATALE: impossibile autenticarsi su Aeterna: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Recupera messaggi da conservare
|
||||
log_section("2. RECUPERO MESSAGGI 'DA CONSERVARE'")
|
||||
messages = await get_pending_conservation_messages()
|
||||
|
||||
if not messages:
|
||||
log("Nessun messaggio con is_pending_conservation=TRUE trovato.")
|
||||
log("Verificare che i messaggi siano stati marcati 'Da conservare' nell'interfaccia.")
|
||||
log("")
|
||||
log("Suggerimento: selezionare un messaggio in PecHub e usare")
|
||||
log("'Aggiungi a Da Conservare' per marcarlo per l'archiviazione.")
|
||||
sys.exit(0)
|
||||
|
||||
log(f"Trovati {len(messages)} messaggi da conservare:")
|
||||
for i, m in enumerate(messages, 1):
|
||||
subj = (m.get("subject") or "")[:60]
|
||||
recv = str(m.get("received_at") or "")[:10]
|
||||
log(f" [{i}] id={str(m['id'])[:8]}... | data={recv} | oggetto={subj}")
|
||||
|
||||
# 3. Trasmissione
|
||||
log_section("3. TRASMISSIONE A AETERNA")
|
||||
results = []
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
msg_id = str(msg["id"])
|
||||
subject = msg.get("subject") or f"PEC {msg_id[:8]}"
|
||||
from_addr = msg.get("from_address")
|
||||
to_addrs = msg.get("to_addresses") or []
|
||||
received_at = str(msg.get("received_at") or "")
|
||||
raw_eml_path = msg.get("raw_eml_path")
|
||||
|
||||
log(f"\nMessaggio [{i}/{len(messages)}]: {subject[:50]}")
|
||||
log(f" ID: {msg_id}")
|
||||
log(f" EML path: {raw_eml_path}")
|
||||
|
||||
# Scarica EML
|
||||
eml_bytes: bytes | None = None
|
||||
if raw_eml_path:
|
||||
log(" Download EML da MinIO ...")
|
||||
eml_bytes = await download_eml_from_minio(raw_eml_path)
|
||||
|
||||
if not eml_bytes:
|
||||
# Crea un EML di test sintetico
|
||||
log(" EML non disponibile. Generazione EML sintetico di test ...")
|
||||
eml_bytes = f"""From: {from_addr or 'test@pec.it'}
|
||||
To: {', '.join(to_addrs) if to_addrs else 'destinatario@pec.it'}
|
||||
Subject: {subject}
|
||||
Date: {received_at}
|
||||
Message-ID: <{msg_id}@pechub.test>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
MIME-Version: 1.0
|
||||
|
||||
Questo e' un messaggio PEC archiviato da PecHub.
|
||||
ID messaggio: {msg_id}
|
||||
Data archiviazione: {datetime.now(UTC).isoformat()}
|
||||
""".encode("utf-8")
|
||||
|
||||
# Costruisci BagIt SIP
|
||||
log(" Costruzione pacchetto BagIt SIP ...")
|
||||
zip_bytes = build_bagit_sip(
|
||||
eml_bytes=eml_bytes,
|
||||
message_id=msg_id,
|
||||
subject=subject,
|
||||
from_address=from_addr,
|
||||
to_addresses=to_addrs,
|
||||
received_at=received_at,
|
||||
)
|
||||
log(f" SIP costruito: {len(zip_bytes):,} bytes")
|
||||
|
||||
# Upload su Aeterna
|
||||
upload_result = await client.upload_sip(
|
||||
zip_bytes=zip_bytes,
|
||||
zip_filename=f"pechub-pec-{msg_id}.zip",
|
||||
title=subject,
|
||||
description=f"Messaggio PEC ID={msg_id} | Da={from_addr} | A={', '.join(to_addrs or [])}",
|
||||
)
|
||||
|
||||
if not upload_result.get("success"):
|
||||
log(f" UPLOAD FALLITO: {upload_result.get('error', 'errore sconosciuto')}")
|
||||
results.append({
|
||||
"message_id": msg_id,
|
||||
"subject": subject,
|
||||
"success": False,
|
||||
"error": upload_result.get("error"),
|
||||
})
|
||||
continue
|
||||
|
||||
package_id = upload_result.get("package_id")
|
||||
pid = upload_result.get("pid", "")
|
||||
log(f" package_id = {package_id}")
|
||||
log(f" pid = {pid}")
|
||||
|
||||
# Polling status (opzionale, non bloccante per il test)
|
||||
log(" Attesa elaborazione pipeline (polling 5 poll x 4s) ...")
|
||||
status_result = await client.poll_status(package_id, max_polls=5, interval=4.0)
|
||||
final_status = status_result.get("final_status", "unknown")
|
||||
log(f" Stato finale Aeterna: {final_status}")
|
||||
|
||||
# Aggiorna DB se accettato
|
||||
if final_status == "ACTIVE" or status_result.get("success") is None:
|
||||
# success=None significa che l'ingest e' ancora in corso ma accettato
|
||||
await mark_message_conserved(msg_id, package_id)
|
||||
|
||||
results.append({
|
||||
"message_id": msg_id,
|
||||
"subject": subject,
|
||||
"success": True,
|
||||
"package_id": package_id,
|
||||
"pid": pid,
|
||||
"final_status": final_status,
|
||||
"latency_ms": upload_result.get("latency_ms"),
|
||||
})
|
||||
|
||||
# 4. Riepilogo
|
||||
log_section("4. RIEPILOGO")
|
||||
ok = sum(1 for r in results if r.get("success"))
|
||||
log(f"Messaggi trasmessi con successo: {ok}/{len(results)}")
|
||||
log("")
|
||||
for r in results:
|
||||
icon = "OK" if r.get("success") else "FAIL"
|
||||
log(f" [{icon}] {r['subject'][:50]}")
|
||||
if r.get("package_id"):
|
||||
log(f" package_id = {r['package_id']}")
|
||||
log(f" pid = {r.get('pid', '-')}")
|
||||
log(f" status = {r.get('final_status', '-')}")
|
||||
log(f" latency = {r.get('latency_ms', '-')} ms")
|
||||
if r.get("error"):
|
||||
log(f" error = {r['error'][:100]}")
|
||||
|
||||
# Salva risultati in JSON
|
||||
output_file = Path("/tmp/aeterna_test_results.json")
|
||||
output_file.write_text(json.dumps(results, indent=2, default=str))
|
||||
log(f"\nRisultati salvati in: {output_file}")
|
||||
|
||||
if ok == len(results) and results:
|
||||
log("\nTest completato con successo!")
|
||||
elif not results:
|
||||
log("\nNessun messaggio trasmesso.")
|
||||
else:
|
||||
log(f"\nTest parzialmente riuscito ({ok}/{len(results)} messaggi trasmessi).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user