Versamento su API AgID

This commit is contained in:
2026-03-19 14:43:36 +01:00
parent 06dfbfcbc4
commit 4e19090f0f
12 changed files with 1319 additions and 3 deletions
@@ -0,0 +1,61 @@
"""Aggiunge la tabella tenant_settings per configurazione archiviazione sostitutiva
Revision ID: 0005
Revises: 0004
Create Date: 2026-03-19 00:00:00.000000
Aggiunge:
- tenant_settings (configurazione per-tenant: modalità archivio mock/produzione,
credenziali conservatore AgID cifrate)
"""
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE tenant_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Archiviazione sostitutiva (Fase 6)
-- 'mock' → usa conservatore simulato in locale (default sicuro per dev)
-- 'production' → usa endpoint reale AgID conservatore
archival_mode VARCHAR(20) NOT NULL DEFAULT 'mock',
-- Identificativo del conservatore (es. 'aruba-cons', 'docuvision', 'mock')
conservatore_id VARCHAR(100) NOT NULL DEFAULT 'mock',
-- URL API conservatore (NULL in modalità mock)
conservatore_endpoint TEXT,
-- Credenziali cifrate AES-256-GCM (NULL in modalità mock)
conservatore_username_enc TEXT,
conservatore_password_enc TEXT,
-- Note operative libere
archival_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_tenant_settings_tenant UNIQUE (tenant_id)
)
""")
op.execute("CREATE INDEX idx_tenant_settings_tenant ON tenant_settings (tenant_id)")
# Trigger updated_at
op.execute("""
CREATE TRIGGER trg_tenant_settings_updated_at
BEFORE UPDATE ON tenant_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS tenant_settings CASCADE")
+59
View File
@@ -0,0 +1,59 @@
"""
Router Impostazioni Tenant (Fase 6).
Endpoint:
GET /settings → legge le impostazioni del tenant corrente (admin)
PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
Solo gli admin e super_admin possono accedere.
La sezione "archiviazione" gestisce il toggle mock/produzione per il
conservatore AgID (Fase 6 Archiviazione Sostitutiva).
"""
from fastapi import APIRouter
from app.dependencies import AdminUser, DB
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
from app.services.tenant_settings_service import TenantSettingsService
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
@router.get(
"",
response_model=TenantSettingsResponse,
summary="Legge le impostazioni del tenant",
description=(
"Restituisce la configurazione operativa del tenant: "
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
),
)
async def get_settings(
current_user: AdminUser,
db: DB,
) -> TenantSettingsResponse:
service = TenantSettingsService(db)
settings = await service.get_or_create(current_user.tenant_id)
return TenantSettingsService.to_response(settings)
@router.put(
"",
response_model=TenantSettingsResponse,
summary="Aggiorna le impostazioni del tenant",
description=(
"Aggiorna la configurazione operativa del tenant. "
"Tutti i campi sono opzionali (semantica PATCH). "
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
),
)
async def update_settings(
body: TenantSettingsUpdate,
current_user: AdminUser,
db: DB,
) -> TenantSettingsResponse:
service = TenantSettingsService(db)
settings = await service.update(current_user.tenant_id, body)
await db.commit()
await db.refresh(settings)
return TenantSettingsService.to_response(settings)
+8
View File
@@ -121,3 +121,11 @@ class ValidationError(HTTPException):
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail, detail=detail,
) )
class BadRequestError(HTTPException):
def __init__(self, detail: str = "Richiesta non valida") -> None:
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)
+2
View File
@@ -14,6 +14,7 @@ from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings from app.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
from app.database import engine from app.database import engine
@@ -94,6 +95,7 @@ app.include_router(ws.router, prefix=API_PREFIX)
app.include_router(virtual_boxes.router, prefix=API_PREFIX) app.include_router(virtual_boxes.router, prefix=API_PREFIX)
app.include_router(notifications.router, prefix=API_PREFIX) app.include_router(notifications.router, prefix=API_PREFIX)
app.include_router(labels.router, prefix=API_PREFIX) app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
# ─── Health check ───────────────────────────────────────────────────────────── # ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -9,3 +9,4 @@ from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401 from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401 from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401 from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
from app.models.tenant_settings import TenantSettings # noqa: F401
+70
View File
@@ -0,0 +1,70 @@
"""
Modello TenantSettings configurazione per-tenant (archiviazione sostitutiva, ecc.).
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TenantSettings(Base):
"""
Configurazione operativa per tenant.
Attualmente gestisce:
- Modalità archiviazione sostitutiva (mock / production)
- Credenziali conservatore AgID (cifrate AES-256-GCM)
Una riga per tenant, creata al primo accesso (upsert).
"""
__tablename__ = "tenant_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False, index=True
)
# ── Archiviazione sostitutiva ─────────────────────────────────────────────
# 'mock' → conservatore simulato (risposte false, default per dev)
# 'production' → endpoint reale AgID conservatore
archival_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="mock"
)
# Identificativo conservatore (es. 'aruba-cons', 'docuvision', 'mock')
conservatore_id: Mapped[str] = mapped_column(
String(100), nullable=False, default="mock"
)
# URL endpoint API conservatore (None in modalità mock)
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
# Credenziali cifrate AES-256-GCM (None in modalità mock)
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Note operative opzionali
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("tenant_id", name="uq_tenant_settings_tenant"),
Index("idx_tenant_settings_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<TenantSettings tenant={self.tenant_id} mode={self.archival_mode!r}>"
+70
View File
@@ -0,0 +1,70 @@
"""
Schema Pydantic per TenantSettings lettura e aggiornamento impostazioni tenant.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl, field_validator
ArchivalMode = Literal["mock", "production"]
class TenantSettingsResponse(BaseModel):
"""
Risposta GET /settings.
Le credenziali del conservatore non vengono mai esposte in chiaro:
vengono restituite come flag booleani (is_configured).
"""
id: uuid.UUID
tenant_id: uuid.UUID
# Archiviazione
archival_mode: ArchivalMode
conservatore_id: str
conservatore_endpoint: str | None
conservatore_username_configured: bool # TRUE se la username è già salvata
conservatore_password_configured: bool # TRUE se la password è già salvata
archival_notes: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TenantSettingsUpdate(BaseModel):
"""
Body PUT /settings.
Tutti i campi sono opzionali (PATCH semantics).
Le credenziali vengono aggiornate solo se fornite.
Un valore esplicitamente None può essere usato per cancellare le credenziali.
"""
archival_mode: ArchivalMode | None = None
conservatore_id: str | None = Field(
default=None, min_length=1, max_length=100
)
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
conservatore_endpoint: str | None = None
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
# Valore stringa vuota ("") = cancella la credenziale.
conservatore_username: str | None = None
conservatore_password: str | None = None
archival_notes: str | None = None
@field_validator("archival_mode")
@classmethod
def validate_archival_mode(cls, v: str | None) -> str | None:
if v is not None and v not in ("mock", "production"):
raise ValueError("archival_mode deve essere 'mock' o 'production'")
return v
@@ -0,0 +1,152 @@
"""
Servizio TenantSettings lettura e aggiornamento configurazione per-tenant.
Responsabilità:
- Upsert della riga settings (crea se non esiste, aggiorna altrimenti)
- Cifratura/decifratura AES-256-GCM delle credenziali conservatore
- Validazione di consistenza (produzione richiede endpoint + credenziali)
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BadRequestError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.tenant_settings import TenantSettings
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
class TenantSettingsService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Lettura (o creazione defaults) ──────────────────────────────────────
async def get_or_create(self, tenant_id: uuid.UUID) -> TenantSettings:
"""
Restituisce le impostazioni del tenant.
Se non esistono, crea una riga con i valori di default (mock).
"""
result = await self.db.execute(
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
)
settings = result.scalar_one_or_none()
if settings is None:
settings = TenantSettings(
tenant_id=tenant_id,
archival_mode="mock",
conservatore_id="mock",
)
self.db.add(settings)
await self.db.flush()
return settings
# ─── Aggiornamento ────────────────────────────────────────────────────────
async def update(
self,
tenant_id: uuid.UUID,
data: TenantSettingsUpdate,
) -> TenantSettings:
"""
Aggiornamento parziale delle impostazioni.
Regole:
- Il passaggio a 'production' è consentito solo se conservatore_endpoint
è già configurato oppure viene fornito nel body corrente.
- Le credenziali vengono cifrate prima del salvataggio.
- Una stringa vuota "" su username/password cancella la credenziale.
"""
settings = await self.get_or_create(tenant_id)
# Applica aggiornamenti campi non-credenziali
if data.archival_mode is not None:
settings.archival_mode = data.archival_mode
if data.conservatore_id is not None:
settings.conservatore_id = data.conservatore_id
if data.conservatore_endpoint is not None:
settings.conservatore_endpoint = data.conservatore_endpoint or None
if data.archival_notes is not None:
settings.archival_notes = data.archival_notes or None
# Aggiorna credenziali (cifra se fornite, cancella se stringa vuota)
if data.conservatore_username is not None:
if data.conservatore_username == "":
settings.conservatore_username_enc = None
else:
settings.conservatore_username_enc = encrypt_credential(
data.conservatore_username
)
if data.conservatore_password is not None:
if data.conservatore_password == "":
settings.conservatore_password_enc = None
else:
settings.conservatore_password_enc = encrypt_credential(
data.conservatore_password
)
# Validazione: produzione richiede endpoint configurato
if settings.archival_mode == "production":
if not settings.conservatore_endpoint:
raise BadRequestError(
"La modalità produzione richiede un endpoint conservatore valido. "
"Inserisci l'URL prima di attivare la modalità produzione."
)
await self.db.flush()
return settings
# ─── Costruzione response ─────────────────────────────────────────────────
@staticmethod
def to_response(settings: TenantSettings) -> TenantSettingsResponse:
"""
Converte il modello in DTO di risposta.
Le credenziali NON vengono mai esposte in chiaro.
"""
return TenantSettingsResponse(
id=settings.id,
tenant_id=settings.tenant_id,
archival_mode=settings.archival_mode, # type: ignore[arg-type]
conservatore_id=settings.conservatore_id,
conservatore_endpoint=settings.conservatore_endpoint,
conservatore_username_configured=settings.conservatore_username_enc is not None,
conservatore_password_configured=settings.conservatore_password_enc is not None,
archival_notes=settings.archival_notes,
created_at=settings.created_at,
updated_at=settings.updated_at,
)
# ─── Helper per il worker (recupera credenziali decifrate) ───────────────
async def get_conservatore_credentials(
self, tenant_id: uuid.UUID
) -> dict:
"""
Restituisce le credenziali del conservatore decifrate.
Usato dal worker per effettuare versamenti.
"""
settings = await self.get_or_create(tenant_id)
return {
"mode": settings.archival_mode,
"conservatore_id": settings.conservatore_id,
"endpoint": settings.conservatore_endpoint,
"username": (
decrypt_credential(settings.conservatore_username_enc)
if settings.conservatore_username_enc
else None
),
"password": (
decrypt_credential(settings.conservatore_password_enc)
if settings.conservatore_password_enc
else None
),
}
+60
View File
@@ -0,0 +1,60 @@
/**
* API client per le impostazioni del tenant.
*
* Endpoint:
* GET /api/v1/settings → legge configurazione (admin)
* PUT /api/v1/settings → aggiorna configurazione (admin)
*/
import { apiClient } from './client'
// ─── Tipi ──────────────────────────────────────────────────────────────────
export type ArchivalMode = 'mock' | 'production'
export interface TenantSettingsResponse {
id: string
tenant_id: string
archival_mode: ArchivalMode
conservatore_id: string
conservatore_endpoint: string | null
conservatore_username_configured: boolean
conservatore_password_configured: boolean
archival_notes: string | null
created_at: string
updated_at: string
}
export interface TenantSettingsUpdate {
archival_mode?: ArchivalMode
conservatore_id?: string
/** URL endpoint API del conservatore (obbligatorio in produzione) */
conservatore_endpoint?: string
/** Username in chiaro viene cifrata lato server. Stringa vuota = cancella */
conservatore_username?: string
/** Password in chiaro viene cifrata lato server. Stringa vuota = cancella */
conservatore_password?: string
archival_notes?: string
}
// ─── Client ────────────────────────────────────────────────────────────────
export const settingsApi = {
/**
* Recupera le impostazioni del tenant corrente.
* Se non esistono, il backend le crea con i valori di default (mock).
*/
get: async (): Promise<TenantSettingsResponse> => {
const { data } = await apiClient.get<TenantSettingsResponse>('/settings')
return data
},
/**
* Aggiorna le impostazioni del tenant.
* Solo i campi forniti vengono modificati (semantica PATCH).
*/
update: async (payload: TenantSettingsUpdate): Promise<TenantSettingsResponse> => {
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
return data
},
}
+456 -3
View File
@@ -1,17 +1,33 @@
/** /**
* SettingsPage impostazioni profilo dell'utente corrente. * SettingsPage impostazioni profilo utente e configurazione tenant (admin).
* *
* Sezioni: * Sezioni:
* - Informazioni profilo (nome visualizzato, email, ruolo) * - Informazioni profilo (nome visualizzato, email, ruolo)
* - Modifica nome * - Modifica nome
* - Cambio password * - Cambio password
* - [Solo admin] Archiviazione Sostitutiva toggle mock/produzione + credenziali conservatore
*/ */
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Settings, User, Lock, Save } from 'lucide-react' import {
Settings,
User,
Lock,
Save,
Archive,
ChevronDown,
ChevronUp,
CheckCircle,
AlertTriangle,
Eye,
EyeOff,
FlaskConical,
Zap,
} from 'lucide-react'
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 { settingsApi, type TenantSettingsResponse, type ArchivalMode } from '@/api/settings.api'
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'
@@ -33,11 +49,31 @@ function roleLabel(role: string): string {
} }
} }
// ─── Badge modalità archiviazione ────────────────────────────────────────────
function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) {
if (mode === 'production') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
<Zap className="h-3 w-3" />
Produzione
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
<FlaskConical className="h-3 w-3" />
Mock (simulato)
</span>
)
}
// ─── Pagina ────────────────────────────────────────────────────────────────── // ─── Pagina ──────────────────────────────────────────────────────────────────
export function SettingsPage() { export function SettingsPage() {
const { user } = useAuth() const { user } = useAuth()
const loadUser = useAuthStore((s) => s.loadUser) const loadUser = useAuthStore((s) => s.loadUser)
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
/* ── Stato modifica nome ── */ /* ── Stato modifica nome ── */
const [fullName, setFullName] = useState(user?.full_name ?? '') const [fullName, setFullName] = useState(user?.full_name ?? '')
@@ -48,6 +84,49 @@ export function SettingsPage() {
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [savingPwd, setSavingPwd] = useState(false) const [savingPwd, setSavingPwd] = useState(false)
/* ── Stato impostazioni archiviazione (admin) ── */
const [archivalSettings, setArchivalSettings] = useState<TenantSettingsResponse | null>(null)
const [loadingArchival, setLoadingArchival] = useState(false)
const [archivalExpanded, setArchivalExpanded] = useState(false)
// Form archiviazione
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
const [conservatoreId, setConservatoreId] = useState('')
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
const [conservatoreUsername, setConservatoreUsername] = useState('')
const [conservatorePassword, setConservatorePassword] = useState('')
const [archivalNotes, setArchivalNotes] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [savingArchival, setSavingArchival] = useState(false)
// Conferma passaggio a produzione
const [showProductionConfirm, setShowProductionConfirm] = useState(false)
/* ── Carica impostazioni archiviazione ── */
useEffect(() => {
if (isAdmin) {
loadArchivalSettings()
}
}, [isAdmin])
const loadArchivalSettings = async () => {
setLoadingArchival(true)
try {
const data = await settingsApi.get()
setArchivalSettings(data)
// Popola form
setArchivalMode(data.archival_mode)
setConservatoreId(data.conservatore_id)
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
setArchivalNotes(data.archival_notes ?? '')
// Username/password non vengono mai restituiti in chiaro
} catch {
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
} finally {
setLoadingArchival(false)
}
}
/* ── Salva nome ── */ /* ── Salva nome ── */
const handleSaveName = async () => { const handleSaveName = async () => {
if (!user) return if (!user) return
@@ -91,6 +170,67 @@ export function SettingsPage() {
} }
} }
/* ── Cambio modalità archiviazione ── */
const handleModeToggle = (newMode: ArchivalMode) => {
if (newMode === 'production' && archivalMode === 'mock') {
// Chiedi conferma prima di passare a produzione
setShowProductionConfirm(true)
} else {
setArchivalMode(newMode)
setShowProductionConfirm(false)
}
}
/* ── Salva impostazioni archiviazione ── */
const handleSaveArchival = async () => {
// Validazione client-side per modalità produzione
if (archivalMode === 'production' && !conservatoreEndpoint.trim()) {
toast.error('La modalità produzione richiede un URL endpoint del conservatore')
return
}
setSavingArchival(true)
try {
const payload: Parameters<typeof settingsApi.update>[0] = {
archival_mode: archivalMode,
conservatore_id: conservatoreId || undefined,
conservatore_endpoint: conservatoreEndpoint || undefined,
archival_notes: archivalNotes || undefined,
}
// Includi credenziali solo se l'utente ha inserito qualcosa
if (conservatoreUsername) {
payload.conservatore_username = conservatoreUsername
}
if (conservatorePassword) {
payload.conservatore_password = conservatorePassword
}
const updated = await settingsApi.update(payload)
setArchivalSettings(updated)
setArchivalMode(updated.archival_mode)
setConservatoreId(updated.conservatore_id)
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
setArchivalNotes(updated.archival_notes ?? '')
// Reset credenziali (non rimostrare in chiaro)
setConservatoreUsername('')
setConservatorePassword('')
setShowProductionConfirm(false)
toast.success(
updated.archival_mode === 'production'
? '✅ Archiviazione attivata in modalità PRODUZIONE'
: '🧪 Archiviazione impostata in modalità mock'
)
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail
toast.error(msg ?? 'Errore durante il salvataggio delle impostazioni')
} finally {
setSavingArchival(false)
}
}
if (!user) return null if (!user) return null
return ( return (
@@ -193,6 +333,319 @@ export function SettingsPage() {
</Button> </Button>
</div> </div>
</Card> </Card>
{/* ── Card: Archiviazione Sostitutiva (solo admin) ── */}
{isAdmin && (
<Card className="p-5">
{/* Header collassabile */}
<button
className="w-full flex items-center justify-between text-left"
onClick={() => setArchivalExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Archive className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Archiviazione Sostitutiva
</h2>
{archivalSettings && (
<ArchivalModeBadge mode={archivalSettings.archival_mode} />
)}
</div>
{archivalExpanded
? <ChevronUp className="h-4 w-4 text-gray-400" />
: <ChevronDown className="h-4 w-4 text-gray-400" />
}
</button>
{archivalExpanded && (
<div className="mt-5 space-y-5">
{loadingArchival ? (
<p className="text-sm text-gray-500 py-4 text-center">
Caricamento impostazioni
</p>
) : (
<>
{/* ── Toggle modalità ── */}
<div className="space-y-2">
<Label>Modalità conservatore</Label>
<p className="text-xs text-gray-500">
In modalità <strong>mock</strong> le operazioni di versamento vengono
simulate localmente senza inviare dati a sistemi esterni. Attiva la
modalità <strong>produzione</strong> solo dopo aver configurato l'endpoint
e le credenziali del conservatore AgID.
</p>
<div className="flex gap-3 mt-2">
{/* Bottone Mock */}
<button
type="button"
onClick={() => handleModeToggle('mock')}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
archivalMode === 'mock'
? 'border-amber-400 bg-amber-50 text-amber-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
<FlaskConical className={`h-6 w-6 ${archivalMode === 'mock' ? 'text-amber-600' : 'text-gray-400'}`} />
<div className="text-center">
<p className="text-sm font-semibold">Mock</p>
<p className="text-xs opacity-75">Simulazione locale</p>
</div>
{archivalMode === 'mock' && (
<CheckCircle className="h-4 w-4 text-amber-600" />
)}
</button>
{/* Bottone Produzione */}
<button
type="button"
onClick={() => handleModeToggle('production')}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
archivalMode === 'production'
? 'border-green-500 bg-green-50 text-green-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
<Zap className={`h-6 w-6 ${archivalMode === 'production' ? 'text-green-600' : 'text-gray-400'}`} />
<div className="text-center">
<p className="text-sm font-semibold">Produzione</p>
<p className="text-xs opacity-75">Conservatore reale AgID</p>
</div>
{archivalMode === 'production' && (
<CheckCircle className="h-4 w-4 text-green-600" />
)}
</button>
</div>
{/* Banner conferma passaggio a produzione */}
{showProductionConfirm && archivalMode === 'mock' && (
<div className="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">
Stai per attivare la modalità produzione
</p>
<p className="text-xs text-amber-700 mt-0.5">
I versamenti verranno inviati al conservatore AgID reale.
Assicurati che l'endpoint e le credenziali siano corretti.
</p>
<div className="flex gap-2 mt-2">
<button
className="text-xs font-medium text-amber-800 underline hover:no-underline"
onClick={() => {
setArchivalMode('production')
setShowProductionConfirm(false)
}}
>
Confermo, attiva produzione
</button>
<span className="text-amber-600">·</span>
<button
className="text-xs font-medium text-gray-600 underline hover:no-underline"
onClick={() => setShowProductionConfirm(false)}
>
Annulla
</button>
</div>
</div>
</div>
)}
</div>
<hr className="border-gray-100" />
{/* ── Identificativo conservatore ── */}
<div className="space-y-1.5">
<Label htmlFor="conservatore_id">Identificativo conservatore</Label>
<Input
id="conservatore_id"
value={conservatoreId}
onChange={(e) => setConservatoreId(e.target.value)}
placeholder="es. aruba-cons, docuvision, namirial"
/>
<p className="text-xs text-gray-500">
Codice identificativo del provider di conservazione (usato nei log dei versamenti).
</p>
</div>
{/* ── Endpoint (mostrato sempre, obbligatorio in produzione) ── */}
<div className="space-y-1.5">
<Label htmlFor="conservatore_endpoint">
URL endpoint API conservatore
{archivalMode === 'production' && (
<span className="ml-1 text-red-500">*</span>
)}
</Label>
<Input
id="conservatore_endpoint"
value={conservatoreEndpoint}
onChange={(e) => setConservatoreEndpoint(e.target.value)}
placeholder="https://conservatore.provider.it/api/v1"
className={
archivalMode === 'production' && !conservatoreEndpoint
? 'border-red-300 focus:ring-red-400'
: ''
}
/>
{archivalMode === 'mock' && (
<p className="text-xs text-gray-400">
Non utilizzato in modalità mock puoi configurarlo in anticipo.
</p>
)}
{archivalMode === 'production' && !conservatoreEndpoint && (
<p className="text-xs text-red-500">
Obbligatorio per la modalità produzione
</p>
)}
</div>
{/* ── Credenziali (mostrate sempre, più evidenti in produzione) ── */}
<div className="space-y-3">
<div>
<Label className="text-sm font-medium text-gray-700">
Credenziali conservatore
</Label>
<p className="text-xs text-gray-500 mt-0.5">
Vengono salvate cifrate (AES-256-GCM).
{archivalSettings?.conservatore_username_configured && (
<span className="ml-1 text-green-600 font-medium">
Username configurata
</span>
)}
{archivalSettings?.conservatore_password_configured && (
<span className="ml-2 text-green-600 font-medium">
Password configurata
</span>
)}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_username">Username</Label>
<Input
id="conservatore_username"
value={conservatoreUsername}
onChange={(e) => setConservatoreUsername(e.target.value)}
placeholder={
archivalSettings?.conservatore_username_configured
? '(credenziale già salvata lascia vuoto per mantenerla)'
: 'Inserisci username conservatore'
}
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_password">Password</Label>
<div className="relative">
<Input
id="conservatore_password"
type={showPassword ? 'text' : 'password'}
value={conservatorePassword}
onChange={(e) => setConservatorePassword(e.target.value)}
placeholder={
archivalSettings?.conservatore_password_configured
? '(credenziale già salvata lascia vuoto per mantenerla)'
: 'Inserisci password conservatore'
}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword
? <EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />
}
</button>
</div>
</div>
</div>
{/* ── Note operative ── */}
<div className="space-y-1.5">
<Label htmlFor="archival_notes">Note operative (opzionale)</Label>
<textarea
id="archival_notes"
value={archivalNotes}
onChange={(e) => setArchivalNotes(e.target.value)}
placeholder="es. Contratto n. 123/2026 scadenza 31/12/2027"
rows={3}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* ── Riepilogo stato corrente ── */}
{archivalSettings && (
<div className={`rounded-lg p-3 text-xs border ${
archivalSettings.archival_mode === 'production'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}>
<p className="font-medium mb-1">
Configurazione attuale (salvata)
</p>
<ul className="space-y-0.5">
<li>
Modalità:{' '}
<strong>
{archivalSettings.archival_mode === 'production'
? 'Produzione'
: 'Mock (simulazione)'}
</strong>
</li>
<li>
Conservatore ID: <strong>{archivalSettings.conservatore_id}</strong>
</li>
{archivalSettings.conservatore_endpoint && (
<li>
Endpoint:{' '}
<strong className="font-mono text-xs break-all">
{archivalSettings.conservatore_endpoint}
</strong>
</li>
)}
<li>
Credenziali:{' '}
{archivalSettings.conservatore_username_configured &&
archivalSettings.conservatore_password_configured
? <strong className="text-green-700">Configurate </strong>
: <span className="text-gray-500">Non configurate</span>
}
</li>
</ul>
</div>
)}
{/* ── Pulsante salva ── */}
<div className="flex justify-end pt-1">
<Button
onClick={handleSaveArchival}
disabled={savingArchival}
className={
archivalMode === 'production'
? 'bg-green-600 hover:bg-green-700'
: ''
}
>
<Save className="h-4 w-4 mr-1.5" />
{savingArchival
? 'Salvataggio…'
: archivalMode === 'production'
? 'Salva e attiva produzione'
: 'Salva impostazioni'}
</Button>
</div>
</>
)}
</div>
)}
</Card>
)}
</div> </div>
) )
} }
+1
View File
@@ -0,0 +1 @@
# Modulo archiviazione sostitutiva (Fase 6)
+379
View File
@@ -0,0 +1,379 @@
"""
Client conservatore AgID supporta modalità mock e produzione.
Modalità mock (default in sviluppo):
- Simula localmente tutte le chiamate al conservatore
- Restituisce risposte sintetiche plausibili (RdV false)
- Non effettua alcuna chiamata di rete esterna
- Utile per sviluppo, test e demo
Modalità produzione:
- Esegue chiamate HTTP reali all'endpoint AgID del conservatore configurato
- Usa le credenziali cifrate recuperate dalle impostazioni del tenant
- Autenticazione HTTP Basic (standard AgID per versamenti SIP)
Come switchare da mock a produzione:
L'admin del tenant configura la modalità dalla pagina Impostazioni del
frontend → sezione "Archiviazione Sostitutiva".
Le credenziali vengono salvate cifrate nel DB (AES-256-GCM, ADR-002).
Il worker legge la configurazione a runtime dalla tabella tenant_settings.
Interfaccia pubblica (stessa per entrambe le modalità):
client = ConservatoreClient.from_tenant_credentials(creds)
result = await client.upload_versamento(sip_path, sip_bytes)
status = await client.get_versamento_status(versamento_id)
dip = await client.get_dip(versamento_id)
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from app.config import get_settings # noqa: F401 (futuro uso logger)
settings = get_settings()
# ─── DTO risultati ────────────────────────────────────────────────────────────
@dataclass
class VersamentoResult:
"""Risultato dell'upload di un pacchetto SIP al conservatore."""
success: bool
versamento_id: str | None = None
message: str = ""
raw_response: dict[str, Any] = field(default_factory=dict)
@dataclass
class VersamentoStatus:
"""Stato di un versamento in corso o completato."""
versamento_id: str
status: str # pending | processing | accepted | rejected
message: str = ""
rdv_available: bool = False
raw_response: dict[str, Any] = field(default_factory=dict)
@dataclass
class DipResult:
"""Risultato di una richiesta DIP (Dissemination Information Package)."""
success: bool
dip_id: str | None = None
download_url: str | None = None
message: str = ""
# ─── Client base (interfaccia) ────────────────────────────────────────────────
class _BaseConservatoreClient:
"""Interfaccia comune per mock e produzione."""
async def upload_versamento(
self,
sip_path: str,
sip_bytes: bytes,
tenant_id: uuid.UUID,
) -> VersamentoResult:
raise NotImplementedError
async def get_versamento_status(
self, versamento_id: str
) -> VersamentoStatus:
raise NotImplementedError
async def get_dip(self, versamento_id: str) -> DipResult:
raise NotImplementedError
# ─── Implementazione MOCK ─────────────────────────────────────────────────────
class MockConservatoreClient(_BaseConservatoreClient):
"""
Conservatore simulato nessuna chiamata di rete.
Simula tempi realistici e risponde con dati fittizi ma strutturalmente
corretti, in modo che il resto del codice possa essere testato end-to-end
senza dipendenze esterne.
"""
async def upload_versamento(
self,
sip_path: str,
sip_bytes: bytes,
tenant_id: uuid.UUID,
) -> VersamentoResult:
"""Simula upload SIP: genera un versamento_id deterministico."""
checksum = hashlib.sha256(sip_bytes).hexdigest()[:16]
tenant_prefix = str(tenant_id)[:8]
versamento_id = f"MOCK-{tenant_prefix}-{checksum}"
return VersamentoResult(
success=True,
versamento_id=versamento_id,
message="[MOCK] Versamento accettato dal conservatore simulato",
raw_response={
"versamento_id": versamento_id,
"stato": "accepted",
"timestamp": datetime.now(UTC).isoformat(),
"mock": True,
},
)
async def get_versamento_status(
self, versamento_id: str
) -> VersamentoStatus:
"""Simula polling stato: risponde sempre 'accepted' con RdV disponibile."""
return VersamentoStatus(
versamento_id=versamento_id,
status="accepted",
message="[MOCK] Versamento confermato",
rdv_available=True,
raw_response={
"versamento_id": versamento_id,
"stato": "accepted",
"rdv_disponibile": True,
"mock": True,
},
)
async def get_dip(self, versamento_id: str) -> DipResult:
"""Simula richiesta DIP."""
dip_id = f"DIP-{versamento_id}"
return DipResult(
success=True,
dip_id=dip_id,
download_url=f"http://mock-conservatore.local/dip/{dip_id}",
message="[MOCK] DIP disponibile",
)
# ─── Implementazione PRODUZIONE ───────────────────────────────────────────────
class ProductionConservatoreClient(_BaseConservatoreClient):
"""
Client HTTP reale per conservatore AgID.
Autenticazione: HTTP Basic (standard AgID CNIPA).
Formato SIP: UNI SInCRO 11386:2023 (pacchetto ZIP con indice XML).
L'URL endpoint e le credenziali arrivano dalla tabella tenant_settings
(decifrate a runtime dal TenantSettingsService).
"""
def __init__(
self,
endpoint: str,
username: str,
password: str,
conservatore_id: str = "production",
timeout_seconds: int = 120,
) -> None:
self.endpoint = endpoint.rstrip("/")
self.username = username
self.password = password
self.conservatore_id = conservatore_id
self.timeout_seconds = timeout_seconds
async def upload_versamento(
self,
sip_path: str,
sip_bytes: bytes,
tenant_id: uuid.UUID,
) -> VersamentoResult:
"""
POST {endpoint}/versamento
Content-Type: application/octet-stream (o multipart/form-data per alcuni provider)
Authorization: Basic base64(user:pass)
"""
try:
import httpx
except ImportError:
return VersamentoResult(
success=False,
message="httpx non installato nel worker. Aggiungere alla dipendenza.",
)
import base64
auth_str = base64.b64encode(
f"{self.username}:{self.password}".encode()
).decode()
try:
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.post(
f"{self.endpoint}/versamento",
content=sip_bytes,
headers={
"Authorization": f"Basic {auth_str}",
"Content-Type": "application/octet-stream",
"X-Tenant-ID": str(tenant_id),
"X-SIP-Path": sip_path,
},
)
if response.status_code in (200, 201, 202):
data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
_fallback_id = str(uuid.uuid4())[:8]
versamento_id = data.get("versamento_id") or data.get("id") or f"VERS-{_fallback_id}"
return VersamentoResult(
success=True,
versamento_id=str(versamento_id),
message="Versamento accettato dal conservatore",
raw_response=data,
)
else:
return VersamentoResult(
success=False,
message=f"Conservatore ha risposto con errore 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 al conservatore: {e}",
)
async def get_versamento_status(
self, versamento_id: str
) -> VersamentoStatus:
"""GET {endpoint}/versamento/{versamento_id}"""
try:
import httpx
except ImportError:
return VersamentoStatus(
versamento_id=versamento_id,
status="unknown",
message="httpx non installato",
)
import base64
auth_str = base64.b64encode(
f"{self.username}:{self.password}".encode()
).decode()
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
f"{self.endpoint}/versamento/{versamento_id}",
headers={"Authorization": f"Basic {auth_str}"},
)
if response.status_code == 200:
data = response.json()
stato = data.get("stato", data.get("status", "unknown"))
return VersamentoStatus(
versamento_id=versamento_id,
status=str(stato),
message=data.get("message", ""),
rdv_available=data.get("rdv_disponibile", False),
raw_response=data,
)
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}",
)
async def get_dip(self, versamento_id: str) -> DipResult:
"""POST {endpoint}/dip con il versamento_id"""
try:
import httpx
except ImportError:
return DipResult(success=False, message="httpx non installato")
import base64
auth_str = base64.b64encode(
f"{self.username}:{self.password}".encode()
).decode()
try:
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{self.endpoint}/dip",
json={"versamento_id": versamento_id},
headers={
"Authorization": f"Basic {auth_str}",
"Content-Type": "application/json",
},
)
if response.status_code in (200, 201, 202):
data = response.json()
return DipResult(
success=True,
dip_id=str(data.get("dip_id", "")),
download_url=data.get("download_url"),
message="DIP disponibile",
)
else:
return DipResult(
success=False,
message=f"HTTP {response.status_code}: {response.text[:200]}",
)
except Exception as e:
return DipResult(success=False, message=f"Errore connessione: {e}")
# ─── Factory ──────────────────────────────────────────────────────────────────
class ConservatoreClient:
"""
Factory che istanzia il client corretto in base alla modalità.
Utilizzo dal worker:
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
client = ConservatoreClient.from_tenant_credentials(creds)
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
"""
@staticmethod
def from_tenant_credentials(creds: dict) -> _BaseConservatoreClient:
"""
Crea il client appropriato dalla configurazione tenant.
Args:
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
con chiavi: mode, conservatore_id, endpoint, username, password
"""
mode = creds.get("mode", "mock")
if mode == "production":
endpoint = creds.get("endpoint")
username = creds.get("username")
password = creds.get("password")
if not endpoint:
raise ValueError(
"Modalità produzione attiva ma conservatore_endpoint non configurato. "
"Verificare le impostazioni del tenant."
)
if not username or not password:
raise ValueError(
"Modalità produzione attiva ma credenziali conservatore mancanti. "
"Configurare username e password nelle impostazioni del tenant."
)
return ProductionConservatoreClient(
endpoint=endpoint,
username=username,
password=password,
conservatore_id=creds.get("conservatore_id", "production"),
)
# Default: modalità mock (sicura per sviluppo)
return MockConservatoreClient()