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>.
|
Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=<uuid>.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated, Optional
|
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.config import get_settings as get_app_settings
|
||||||
from app.dependencies import AdminUser, DB
|
from app.dependencies import AdminUser, DB
|
||||||
from app.schemas.tenant_settings import (
|
from app.schemas.tenant_settings import (
|
||||||
|
ConservatoreTestResult,
|
||||||
IndexingJobStatus,
|
IndexingJobStatus,
|
||||||
IndexingStats,
|
IndexingStats,
|
||||||
StartReindexRequest,
|
StartReindexRequest,
|
||||||
@@ -59,15 +61,21 @@ def _resolve_tenant_id(
|
|||||||
summary="Legge le impostazioni del tenant",
|
summary="Legge le impostazioni del tenant",
|
||||||
description=(
|
description=(
|
||||||
"Restituisce la configurazione operativa del tenant: "
|
"Restituisce la configurazione operativa del tenant: "
|
||||||
"modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
|
"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(
|
async def get_settings(
|
||||||
current_user: AdminUser,
|
current_user: AdminUser,
|
||||||
db: DB,
|
db: DB,
|
||||||
|
tenant_id: Optional[uuid.UUID] = Query(
|
||||||
|
default=None,
|
||||||
|
description="(solo super_admin) UUID del tenant su cui operare",
|
||||||
|
),
|
||||||
) -> TenantSettingsResponse:
|
) -> TenantSettingsResponse:
|
||||||
|
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||||
service = TenantSettingsService(db)
|
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)
|
return TenantSettingsService.to_response(settings)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,21 +86,176 @@ async def get_settings(
|
|||||||
description=(
|
description=(
|
||||||
"Aggiorna la configurazione operativa del tenant. "
|
"Aggiorna la configurazione operativa del tenant. "
|
||||||
"Tutti i campi sono opzionali (semantica PATCH). "
|
"Tutti i campi sono opzionali (semantica PATCH). "
|
||||||
"Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato."
|
"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(
|
async def update_settings(
|
||||||
body: TenantSettingsUpdate,
|
body: TenantSettingsUpdate,
|
||||||
current_user: AdminUser,
|
current_user: AdminUser,
|
||||||
db: DB,
|
db: DB,
|
||||||
|
tenant_id: Optional[uuid.UUID] = Query(
|
||||||
|
default=None,
|
||||||
|
description="(solo super_admin) UUID del tenant su cui operare",
|
||||||
|
),
|
||||||
) -> TenantSettingsResponse:
|
) -> TenantSettingsResponse:
|
||||||
|
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||||
service = TenantSettingsService(db)
|
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.commit()
|
||||||
await db.refresh(settings)
|
await db.refresh(settings)
|
||||||
return TenantSettingsService.to_response(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 ─────────────────────────────────────────────────
|
# ─── Indicizzazione full-text ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ class Mailbox(Base):
|
|||||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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(
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
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)
|
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
|
||||||
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, 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)
|
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
|
# 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_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
conservatore_password_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
|
# Note operative opzionali
|
||||||
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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")
|
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...)")
|
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)
|
# Credenziali IMAP (in chiaro, cifrate prima della persistenza)
|
||||||
imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP")
|
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")
|
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)
|
provider: str | None = Field(None, max_length=100)
|
||||||
status: Literal["active", "paused"] | None = None
|
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)
|
# Aggiornamento credenziali IMAP (opzionale)
|
||||||
imap_host: str | None = Field(None, min_length=1, max_length=255)
|
imap_host: str | None = Field(None, min_length=1, max_length=255)
|
||||||
imap_port: int | None = Field(None, ge=1, le=65535)
|
imap_port: int | None = Field(None, ge=1, le=65535)
|
||||||
@@ -84,6 +99,10 @@ class MailboxResponse(BaseModel):
|
|||||||
smtp_port: int
|
smtp_port: int
|
||||||
smtp_use_tls: bool
|
smtp_use_tls: bool
|
||||||
|
|
||||||
|
# Protocollo (Feature N8)
|
||||||
|
protocol_type: str
|
||||||
|
rem_provider: str | None
|
||||||
|
|
||||||
# Stato sync
|
# Stato sync
|
||||||
status: str
|
status: str
|
||||||
last_sync_at: datetime | None
|
last_sync_at: datetime | None
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class TenantSettingsResponse(BaseModel):
|
|||||||
archival_mode: ArchivalMode
|
archival_mode: ArchivalMode
|
||||||
conservatore_id: str
|
conservatore_id: str
|
||||||
conservatore_endpoint: str | None
|
conservatore_endpoint: str | None
|
||||||
|
conservatore_tenant_slug: str | None
|
||||||
conservatore_username_configured: bool # TRUE se la username è già salvata
|
conservatore_username_configured: bool # TRUE se la username è già salvata
|
||||||
conservatore_password_configured: bool # TRUE se la password è già salvata
|
conservatore_password_configured: bool # TRUE se la password è già salvata
|
||||||
archival_notes: str | None
|
archival_notes: str | None
|
||||||
@@ -56,6 +57,9 @@ class TenantSettingsUpdate(BaseModel):
|
|||||||
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
|
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
|
||||||
conservatore_endpoint: str | None = None
|
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.
|
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
|
||||||
# Valore stringa vuota ("") = cancella la credenziale.
|
# Valore stringa vuota ("") = cancella la credenziale.
|
||||||
conservatore_username: str | None = None
|
conservatore_username: str | None = None
|
||||||
@@ -103,6 +107,14 @@ class IndexingJobStatus(BaseModel):
|
|||||||
error: Optional[str] = None
|
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):
|
class StartReindexRequest(BaseModel):
|
||||||
"""Body per POST /settings/indexing/reindex."""
|
"""Body per POST /settings/indexing/reindex."""
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ class TenantSettingsService:
|
|||||||
if data.conservatore_endpoint is not None:
|
if data.conservatore_endpoint is not None:
|
||||||
settings.conservatore_endpoint = data.conservatore_endpoint or 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:
|
if data.archival_notes is not None:
|
||||||
settings.archival_notes = data.archival_notes or None
|
settings.archival_notes = data.archival_notes or None
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ class TenantSettingsService:
|
|||||||
archival_mode=settings.archival_mode, # type: ignore[arg-type]
|
archival_mode=settings.archival_mode, # type: ignore[arg-type]
|
||||||
conservatore_id=settings.conservatore_id,
|
conservatore_id=settings.conservatore_id,
|
||||||
conservatore_endpoint=settings.conservatore_endpoint,
|
conservatore_endpoint=settings.conservatore_endpoint,
|
||||||
|
conservatore_tenant_slug=settings.conservatore_tenant_slug,
|
||||||
conservatore_username_configured=settings.conservatore_username_enc is not None,
|
conservatore_username_configured=settings.conservatore_username_enc is not None,
|
||||||
conservatore_password_configured=settings.conservatore_password_enc is not None,
|
conservatore_password_configured=settings.conservatore_password_enc is not None,
|
||||||
archival_notes=settings.archival_notes,
|
archival_notes=settings.archival_notes,
|
||||||
@@ -139,6 +143,7 @@ class TenantSettingsService:
|
|||||||
"mode": settings.archival_mode,
|
"mode": settings.archival_mode,
|
||||||
"conservatore_id": settings.conservatore_id,
|
"conservatore_id": settings.conservatore_id,
|
||||||
"endpoint": settings.conservatore_endpoint,
|
"endpoint": settings.conservatore_endpoint,
|
||||||
|
"tenant_slug": settings.conservatore_tenant_slug,
|
||||||
"username": (
|
"username": (
|
||||||
decrypt_credential(settings.conservatore_username_enc)
|
decrypt_credential(settings.conservatore_username_enc)
|
||||||
if 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
|
archival_mode: ArchivalMode
|
||||||
conservatore_id: string
|
conservatore_id: string
|
||||||
conservatore_endpoint: string | null
|
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_username_configured: boolean
|
||||||
conservatore_password_configured: boolean
|
conservatore_password_configured: boolean
|
||||||
archival_notes: string | null
|
archival_notes: string | null
|
||||||
@@ -34,6 +36,8 @@ export interface TenantSettingsUpdate {
|
|||||||
conservatore_id?: string
|
conservatore_id?: string
|
||||||
/** URL endpoint API del conservatore (obbligatorio in produzione) */
|
/** URL endpoint API del conservatore (obbligatorio in produzione) */
|
||||||
conservatore_endpoint?: string
|
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 */
|
/** Username in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||||
conservatore_username?: string
|
conservatore_username?: string
|
||||||
/** Password in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
/** Password in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||||
@@ -41,6 +45,13 @@ export interface TenantSettingsUpdate {
|
|||||||
archival_notes?: string
|
archival_notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConservatoreTestResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
latency_ms: number | null
|
||||||
|
provider_info: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
|
// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
|
||||||
|
|
||||||
export interface IndexingStats {
|
export interface IndexingStats {
|
||||||
@@ -76,20 +87,35 @@ export interface IndexingJobStatus {
|
|||||||
|
|
||||||
export const settingsApi = {
|
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).
|
* 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> => {
|
get: async (tenantId?: string): Promise<TenantSettingsResponse> => {
|
||||||
const { data } = await apiClient.get<TenantSettingsResponse>('/settings')
|
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||||
|
const { data } = await apiClient.get<TenantSettingsResponse>('/settings', { params })
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggiorna le impostazioni del tenant.
|
* Aggiorna le impostazioni del tenant.
|
||||||
* Solo i campi forniti vengono modificati (semantica PATCH).
|
* Solo i campi forniti vengono modificati (semantica PATCH).
|
||||||
|
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||||
*/
|
*/
|
||||||
update: async (payload: TenantSettingsUpdate): Promise<TenantSettingsResponse> => {
|
update: async (payload: TenantSettingsUpdate, tenantId?: string): Promise<TenantSettingsResponse> => {
|
||||||
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
|
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
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
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 { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@@ -174,6 +175,14 @@ export function MailboxesPage() {
|
|||||||
{mailbox.provider}
|
{mailbox.provider}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{mailbox.email_address}</p>
|
<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">
|
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
@@ -278,6 +287,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<MailboxCreateRequest>({
|
} = useForm<MailboxCreateRequest>({
|
||||||
defaultValues: editingMailbox
|
defaultValues: editingMailbox
|
||||||
@@ -285,6 +295,8 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
|||||||
email_address: editingMailbox.email_address,
|
email_address: editingMailbox.email_address,
|
||||||
display_name: editingMailbox.display_name || '',
|
display_name: editingMailbox.display_name || '',
|
||||||
provider: editingMailbox.provider || '',
|
provider: editingMailbox.provider || '',
|
||||||
|
protocol_type: editingMailbox.protocol_type || 'pec_it',
|
||||||
|
rem_provider: editingMailbox.rem_provider || '',
|
||||||
imap_host: editingMailbox.imap_host,
|
imap_host: editingMailbox.imap_host,
|
||||||
imap_port: editingMailbox.imap_port,
|
imap_port: editingMailbox.imap_port,
|
||||||
imap_user: editingMailbox.email_address,
|
imap_user: editingMailbox.email_address,
|
||||||
@@ -295,6 +307,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
|||||||
smtp_use_tls: editingMailbox.smtp_use_tls,
|
smtp_use_tls: editingMailbox.smtp_use_tls,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
protocol_type: 'pec_it',
|
||||||
imap_port: 993,
|
imap_port: 993,
|
||||||
smtp_port: 465,
|
smtp_port: 465,
|
||||||
imap_use_ssl: true,
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: mailboxesApi.create,
|
mutationFn: mailboxesApi.create,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -363,6 +380,41 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Separatore IMAP */}
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
<h4 className="text-sm font-semibold">Configurazione IMAP (ricezione)</h4>
|
<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 { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -309,8 +309,22 @@ function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messa
|
|||||||
queryFn: () => labelsApi.list(),
|
queryFn: () => labelsApi.list(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Le label tassonomiche del messaggio sono quelle con parent_id != null
|
// Le label tassonomiche del messaggio: nodi figli (parent_id != null) PIU' i nodi
|
||||||
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
|
// 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"
|
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
|
||||||
function buildPath(labelId: string): string {
|
function buildPath(labelId: string): string {
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ import {
|
|||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useAuthStore } from '@/store/auth.store'
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
import { usersApi } from '@/api/users.api'
|
import { usersApi } from '@/api/users.api'
|
||||||
|
import { tenantsApi } from '@/api/tenants.api'
|
||||||
|
import type { TenantResponse } from '@/types/api.types'
|
||||||
import {
|
import {
|
||||||
settingsApi,
|
settingsApi,
|
||||||
type TenantSettingsResponse,
|
type TenantSettingsResponse,
|
||||||
type ArchivalMode,
|
type ArchivalMode,
|
||||||
|
type ConservatoreTestResult,
|
||||||
type IndexingStats,
|
type IndexingStats,
|
||||||
type IndexingJobStatus,
|
type IndexingJobStatus,
|
||||||
type ReindexMode,
|
type ReindexMode,
|
||||||
@@ -973,32 +976,53 @@ export function SettingsPage() {
|
|||||||
const [loadingArchival, setLoadingArchival] = useState(false)
|
const [loadingArchival, setLoadingArchival] = useState(false)
|
||||||
const [archivalExpanded, setArchivalExpanded] = 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
|
// Form archiviazione
|
||||||
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
|
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
|
||||||
const [conservatoreId, setConservatoreId] = useState('')
|
const [conservatoreId, setConservatoreId] = useState('')
|
||||||
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
|
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
|
||||||
|
const [conservatoreTenantSlug, setConservatoreTenantSlug] = useState('')
|
||||||
const [conservatoreUsername, setConservatoreUsername] = useState('')
|
const [conservatoreUsername, setConservatoreUsername] = useState('')
|
||||||
const [conservatorePassword, setConservatorePassword] = useState('')
|
const [conservatorePassword, setConservatorePassword] = useState('')
|
||||||
const [archivalNotes, setArchivalNotes] = useState('')
|
const [archivalNotes, setArchivalNotes] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [savingArchival, setSavingArchival] = useState(false)
|
const [savingArchival, setSavingArchival] = useState(false)
|
||||||
const [showProductionConfirm, setShowProductionConfirm] = 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 ── */
|
/* ── Carica impostazioni archiviazione ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadArchivalSettings()
|
loadArchivalSettings(selectedTenantId)
|
||||||
}
|
}
|
||||||
}, [isAdmin])
|
}, [isAdmin, selectedTenantId])
|
||||||
|
|
||||||
const loadArchivalSettings = async () => {
|
const loadArchivalSettings = async (tenantId?: string) => {
|
||||||
setLoadingArchival(true)
|
setLoadingArchival(true)
|
||||||
|
// Resetta credenziali ogni volta che si cambia tenant
|
||||||
|
setConservatoreUsername('')
|
||||||
|
setConservatorePassword('')
|
||||||
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.get()
|
const data = await settingsApi.get(tenantId)
|
||||||
setArchivalSettings(data)
|
setArchivalSettings(data)
|
||||||
setArchivalMode(data.archival_mode)
|
setArchivalMode(data.archival_mode)
|
||||||
setConservatoreId(data.conservatore_id)
|
setConservatoreId(data.conservatore_id)
|
||||||
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
|
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
|
||||||
|
setConservatoreTenantSlug(data.conservatore_tenant_slug ?? '')
|
||||||
setArchivalNotes(data.archival_notes ?? '')
|
setArchivalNotes(data.archival_notes ?? '')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
|
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 ── */
|
/* ── Cambio modalita' archiviazione ── */
|
||||||
const handleModeToggle = (newMode: ArchivalMode) => {
|
const handleModeToggle = (newMode: ArchivalMode) => {
|
||||||
if (newMode === 'production' && archivalMode === 'mock') {
|
if (newMode === 'production' && archivalMode === 'mock') {
|
||||||
@@ -1073,21 +1118,24 @@ export function SettingsPage() {
|
|||||||
archival_mode: archivalMode,
|
archival_mode: archivalMode,
|
||||||
conservatore_id: conservatoreId || undefined,
|
conservatore_id: conservatoreId || undefined,
|
||||||
conservatore_endpoint: conservatoreEndpoint || undefined,
|
conservatore_endpoint: conservatoreEndpoint || undefined,
|
||||||
|
conservatore_tenant_slug: conservatoreTenantSlug || undefined,
|
||||||
archival_notes: archivalNotes || undefined,
|
archival_notes: archivalNotes || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conservatoreUsername) payload.conservatore_username = conservatoreUsername
|
if (conservatoreUsername) payload.conservatore_username = conservatoreUsername
|
||||||
if (conservatorePassword) payload.conservatore_password = conservatorePassword
|
if (conservatorePassword) payload.conservatore_password = conservatorePassword
|
||||||
|
|
||||||
const updated = await settingsApi.update(payload)
|
const updated = await settingsApi.update(payload, selectedTenantId)
|
||||||
setArchivalSettings(updated)
|
setArchivalSettings(updated)
|
||||||
setArchivalMode(updated.archival_mode)
|
setArchivalMode(updated.archival_mode)
|
||||||
setConservatoreId(updated.conservatore_id)
|
setConservatoreId(updated.conservatore_id)
|
||||||
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
|
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
|
||||||
|
setConservatoreTenantSlug(updated.conservatore_tenant_slug ?? '')
|
||||||
setArchivalNotes(updated.archival_notes ?? '')
|
setArchivalNotes(updated.archival_notes ?? '')
|
||||||
setConservatoreUsername('')
|
setConservatoreUsername('')
|
||||||
setConservatorePassword('')
|
setConservatorePassword('')
|
||||||
setShowProductionConfirm(false)
|
setShowProductionConfirm(false)
|
||||||
|
setTestResult(null)
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
updated.archival_mode === 'production'
|
updated.archival_mode === 'production'
|
||||||
@@ -1231,6 +1279,41 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{archivalExpanded && (
|
{archivalExpanded && (
|
||||||
<div className="mt-5 space-y-5">
|
<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 ? (
|
{loadingArchival ? (
|
||||||
<p className="text-sm text-gray-500 py-4 text-center">
|
<p className="text-sm text-gray-500 py-4 text-center">
|
||||||
Caricamento impostazioni...
|
Caricamento impostazioni...
|
||||||
@@ -1334,7 +1417,21 @@ export function SettingsPage() {
|
|||||||
id="conservatore_endpoint"
|
id="conservatore_endpoint"
|
||||||
value={conservatoreEndpoint}
|
value={conservatoreEndpoint}
|
||||||
onChange={(e) => setConservatoreEndpoint(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -1398,6 +1495,7 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Riepilogo configurazione salvata ── */}
|
||||||
{archivalSettings && (
|
{archivalSettings && (
|
||||||
<div className={`rounded-lg p-3 text-xs border ${
|
<div className={`rounded-lg p-3 text-xs border ${
|
||||||
archivalSettings.archival_mode === 'production'
|
archivalSettings.archival_mode === 'production'
|
||||||
@@ -1411,6 +1509,9 @@ export function SettingsPage() {
|
|||||||
{archivalSettings.conservatore_endpoint && (
|
{archivalSettings.conservatore_endpoint && (
|
||||||
<li>Endpoint: <strong className="font-mono text-xs break-all">{archivalSettings.conservatore_endpoint}</strong></li>
|
<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: {
|
<li>Credenziali: {
|
||||||
archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
|
archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
|
||||||
? <strong className="text-green-700">Configurate</strong>
|
? <strong className="text-green-700">Configurate</strong>
|
||||||
@@ -1420,7 +1521,51 @@ export function SettingsPage() {
|
|||||||
</div>
|
</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
|
<Button
|
||||||
onClick={handleSaveArchival}
|
onClick={handleSaveArchival}
|
||||||
disabled={savingArchival}
|
disabled={savingArchival}
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface UserUpdateRequest {
|
|||||||
|
|
||||||
export type MailboxStatus = 'active' | 'paused' | 'error' | 'deleted'
|
export type MailboxStatus = 'active' | 'paused' | 'error' | 'deleted'
|
||||||
|
|
||||||
|
export type MailboxProtocol = 'pec_it' | 'rem_eu'
|
||||||
|
|
||||||
export interface MailboxResponse {
|
export interface MailboxResponse {
|
||||||
id: string
|
id: string
|
||||||
tenant_id: string
|
tenant_id: string
|
||||||
@@ -114,6 +116,9 @@ export interface MailboxResponse {
|
|||||||
smtp_host: string
|
smtp_host: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
smtp_use_tls: boolean
|
smtp_use_tls: boolean
|
||||||
|
// Protocollo (Feature N8 – REM europea)
|
||||||
|
protocol_type: MailboxProtocol
|
||||||
|
rem_provider: string | null
|
||||||
status: MailboxStatus
|
status: MailboxStatus
|
||||||
last_sync_at: string | null
|
last_sync_at: string | null
|
||||||
last_sync_uid: number | null
|
last_sync_uid: number | null
|
||||||
@@ -135,6 +140,9 @@ export interface MailboxCreateRequest {
|
|||||||
email_address: string
|
email_address: string
|
||||||
display_name?: string
|
display_name?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
|
// Protocollo (Feature N8 – REM europea)
|
||||||
|
protocol_type?: MailboxProtocol
|
||||||
|
rem_provider?: string
|
||||||
imap_host: string
|
imap_host: string
|
||||||
imap_port: number
|
imap_port: number
|
||||||
imap_user: string
|
imap_user: string
|
||||||
@@ -151,6 +159,9 @@ export interface MailboxUpdateRequest {
|
|||||||
display_name?: string
|
display_name?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
status?: 'active' | 'paused'
|
status?: 'active' | 'paused'
|
||||||
|
// Protocollo (Feature N8)
|
||||||
|
protocol_type?: MailboxProtocol
|
||||||
|
rem_provider?: string | null
|
||||||
imap_host?: string
|
imap_host?: string
|
||||||
imap_port?: number
|
imap_port?: number
|
||||||
imap_user?: string
|
imap_user?: string
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ Modalità mock (default in sviluppo):
|
|||||||
- Utile per sviluppo, test e demo
|
- Utile per sviluppo, test e demo
|
||||||
|
|
||||||
Modalità produzione:
|
Modalità produzione:
|
||||||
- Esegue chiamate HTTP reali all'endpoint AgID del conservatore configurato
|
- Supporta Aeterna (api.aeterna.idrainformatica.it) con autenticazione JWT
|
||||||
- Usa le credenziali cifrate recuperate dalle impostazioni del tenant
|
- Costruisce pacchetti SIP in formato BagIt RFC 8493 (auto-rilevato da Aeterna)
|
||||||
- Autenticazione HTTP Basic (standard AgID per versamenti SIP)
|
- 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:
|
Come switchare da mock a produzione:
|
||||||
L'admin del tenant configura la modalità dalla pagina Impostazioni del
|
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à):
|
Interfaccia pubblica (stessa per entrambe le modalità):
|
||||||
client = ConservatoreClient.from_tenant_credentials(creds)
|
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)
|
status = await client.get_versamento_status(versamento_id)
|
||||||
dip = await client.get_dip(versamento_id)
|
dip = await client.get_dip(versamento_id)
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +30,10 @@ Interfaccia pubblica (stessa per entrambe le modalità):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
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):
|
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).
|
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
|
L'URL endpoint e le credenziali arrivano dalla tabella tenant_settings
|
||||||
(decifrate a runtime dal TenantSettingsService).
|
(decifrate a runtime dal TenantSettingsService).
|
||||||
"""
|
"""
|
||||||
@@ -187,7 +725,7 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
|
|||||||
) -> VersamentoResult:
|
) -> VersamentoResult:
|
||||||
"""
|
"""
|
||||||
POST {endpoint}/versamento
|
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)
|
Authorization: Basic base64(user:pass)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -333,12 +871,17 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
|
|||||||
|
|
||||||
class ConservatoreClient:
|
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:
|
Utilizzo dal worker:
|
||||||
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
|
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
|
||||||
client = ConservatoreClient.from_tenant_credentials(creds)
|
client = ConservatoreClient.from_tenant_credentials(creds)
|
||||||
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
|
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
|
@staticmethod
|
||||||
@@ -348,7 +891,8 @@ class ConservatoreClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
|
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")
|
mode = creds.get("mode", "mock")
|
||||||
|
|
||||||
@@ -356,6 +900,7 @@ class ConservatoreClient:
|
|||||||
endpoint = creds.get("endpoint")
|
endpoint = creds.get("endpoint")
|
||||||
username = creds.get("username")
|
username = creds.get("username")
|
||||||
password = creds.get("password")
|
password = creds.get("password")
|
||||||
|
conservatore_id = creds.get("conservatore_id", "production")
|
||||||
|
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -368,11 +913,33 @@ class ConservatoreClient:
|
|||||||
"Configurare username e password nelle impostazioni del tenant."
|
"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(
|
return ProductionConservatoreClient(
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
conservatore_id=creds.get("conservatore_id", "production"),
|
conservatore_id=conservatore_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default: modalità mock (sicura per sviluppo)
|
# 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.jobs.index_message import index_message
|
||||||
from app.models import Attachment, Mailbox, Message
|
from app.models import Attachment, Mailbox, Message
|
||||||
from app.parsers.eml_parser import parse_eml
|
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
|
from app.storage.minio_client import upload_attachment, upload_eml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
|
||||||
return False
|
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
|
# La classificazione avviene PRIMA del parsing completo perche' il parser
|
||||||
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
|
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
|
||||||
# il body_text (testo della ricevuta) con il contenuto di postacert.eml.
|
# 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)
|
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_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) ──────────
|
# ── 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)
|
received_at = datetime.now(UTC)
|
||||||
|
|
||||||
# ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─
|
# ── 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)
|
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
|
||||||
parent_message_id: uuid.UUID | None = None
|
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:
|
try:
|
||||||
parent_message_id = await _apply_outbound_state_machine(
|
parent_message_id = await _apply_outbound_state_machine(
|
||||||
riferimento_message_id=pec_class.riferimento_message_id,
|
riferimento_message_id=_riferimento_message_id,
|
||||||
pec_type=pec_class.pec_type,
|
pec_type=_pec_type,
|
||||||
tenant_id=mailbox.tenant_id,
|
tenant_id=mailbox.tenant_id,
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
except Exception as bind_err:
|
except Exception as bind_err:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato "
|
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,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Non interrompere il salvataggio della ricevuta: il record viene
|
# Non interrompere il salvataggio della ricevuta: il record viene
|
||||||
@@ -648,7 +681,9 @@ async def _save_message(
|
|||||||
imap_folder=imap_folder,
|
imap_folder=imap_folder,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
state=state,
|
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,
|
subject=parsed.subject,
|
||||||
from_address=parsed.from_address,
|
from_address=parsed.from_address,
|
||||||
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
|
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 "",
|
"from_address": message.from_address or "",
|
||||||
"pec_type": message.pec_type,
|
"pec_type": message.pec_type,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"is_receipt": pec_class.is_receipt,
|
"is_receipt": _is_receipt,
|
||||||
"received_at": received_at.isoformat(),
|
"received_at": received_at.isoformat(),
|
||||||
}
|
}
|
||||||
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
|
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
|
||||||
@@ -696,7 +731,7 @@ async def _save_message(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} folder={imap_folder!r} "
|
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)}"
|
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -719,7 +754,8 @@ async def _save_message(
|
|||||||
|
|
||||||
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
|
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
|
||||||
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
|
# 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:
|
try:
|
||||||
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
|
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -728,7 +764,7 @@ async def _save_message(
|
|||||||
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
|
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
|
||||||
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
||||||
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
|
# 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:
|
try:
|
||||||
from sqlalchemy import text as _text
|
from sqlalchemy import text as _text
|
||||||
await db.execute(
|
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
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from arq import run_worker
|
from arq import cron, run_worker
|
||||||
from arq.connections import RedisSettings
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.imap.pool import MailboxPool
|
from app.imap.pool import MailboxPool
|
||||||
from app.jobs.apply_routing_rules import apply_routing_rules
|
from app.jobs.apply_routing_rules import apply_routing_rules
|
||||||
from app.jobs.dispatch_notification import dispatch_notification
|
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.send_pec import send_pec
|
||||||
from app.jobs.sync_mailbox import sync_mailbox
|
from app.jobs.sync_mailbox import sync_mailbox
|
||||||
from app.smtp.receipt_watcher import watch_receipt
|
from app.smtp.receipt_watcher import watch_receipt
|
||||||
@@ -133,9 +134,17 @@ def _parse_redis_settings() -> RedisSettings:
|
|||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
"""Configurazione del worker arq."""
|
"""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]
|
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
|
# Callbacks lifecycle
|
||||||
on_startup = on_startup
|
on_startup = on_startup
|
||||||
on_shutdown = on_shutdown
|
on_shutdown = on_shutdown
|
||||||
@@ -148,7 +157,8 @@ class WorkerSettings:
|
|||||||
|
|
||||||
# Timeout per ogni job (secondi)
|
# Timeout per ogni job (secondi)
|
||||||
# send_pec può richiedere più tempo su SMTP lenti
|
# 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
|
# Retry automatico in caso di errore
|
||||||
max_tries = 3
|
max_tries = 3
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class Mailbox(Base):
|
|||||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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_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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -121,6 +125,10 @@ class Message(Base):
|
|||||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
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)
|
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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):
|
class NotificationLog(Base):
|
||||||
"""
|
"""
|
||||||
Log di ogni tentativo di notifica con retry e circuit breaker.
|
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 ───────────────────────────────────────────────────────────
|
# ─── 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_SYSTEM_FILENAMES = frozenset({
|
||||||
|
# ── PEC italiana (DM 2 novembre 2005) ─────────────────────────────────────
|
||||||
"daticert.xml",
|
"daticert.xml",
|
||||||
"postacert.eml",
|
"postacert.eml",
|
||||||
"smime.p7s",
|
"smime.p7s",
|
||||||
"smime.p7m",
|
"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
|
# 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)
|
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:
|
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Applica la state machine al messaggio outbound.
|
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