diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 506da0f..ef93727 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -24,6 +24,10 @@ from fastapi import APIRouter, HTTPException, Query, status from app.config import get_settings as get_app_settings from app.dependencies import AdminUser, DB from app.schemas.tenant_settings import ( + ConservationStats, + ConservationStatsResponse, + ConservationTenantBreakdown, + ConservationTriggerResult, ConservatoreTestResult, IndexingJobStatus, IndexingStats, @@ -418,6 +422,177 @@ async def get_rescan_status( return IndexingJobStatus(**status_data) +# ─── Conservazione sostitutiva – statistiche e trigger ─────────────────────── + +def _build_conservation_stats( + total: int, + conserved: int, + pending: int, + last_conserved_at, +) -> ConservationStats: + not_queued = max(0, total - conserved - pending) + coverage_pct = round((conserved / total * 100), 1) if total > 0 else 0.0 + last_dt = last_conserved_at.isoformat() if last_conserved_at else None + return ConservationStats( + total_messages=total, + conserved=conserved, + pending=pending, + not_queued=not_queued, + coverage_pct=coverage_pct, + last_conserved_at=last_dt, + ) + + +@router.get( + "/conservation/stats", + response_model=ConservationStatsResponse, + summary="Statistiche conservazione sostitutiva", + description=( + "Restituisce il numero di messaggi totali, conservati, in coda e non accodati. " + "admin: statistiche del proprio tenant. " + "super_admin con ?tenant_id=: statistiche del tenant specificato. " + "super_admin senza tenant_id: statistiche aggregate su tutti i tenant + breakdown per-tenant." + ), +) +async def get_conservation_stats( + current_user: AdminUser, + db: DB, + tenant_id: Optional[uuid.UUID] = Query( + default=None, + description="(solo super_admin) UUID del tenant su cui operare; assente = tutti i tenant", + ), +) -> ConservationStatsResponse: + from sqlalchemy import func, select + from app.models.message import Message + from app.models.tenant import Tenant + + # Se non e' super_admin, oppure e' super_admin con tenant_id specificato -> singolo tenant + if current_user.role != "super_admin" or tenant_id is not None: + target_tenant_id = _resolve_tenant_id(current_user, tenant_id) + + row = await db.execute( + select( + func.count().label("total"), + func.count().filter(Message.is_conserved == True).label("conserved"), # noqa: E712 + func.count().filter( + Message.is_pending_conservation == True, # noqa: E712 + Message.is_conserved == False, # noqa: E712 + ).label("pending"), + func.max(Message.conserved_at).label("last_conserved_at"), + ).where(Message.tenant_id == target_tenant_id) + ) + r = row.one() + stats = _build_conservation_stats( + total=r.total or 0, + conserved=r.conserved or 0, + pending=r.pending or 0, + last_conserved_at=r.last_conserved_at, + ) + return ConservationStatsResponse(stats=stats, per_tenant=None) + + # super_admin senza tenant_id: aggregato + per-tenant + rows = await db.execute( + select( + Message.tenant_id, + func.count().label("total"), + func.count().filter(Message.is_conserved == True).label("conserved"), # noqa: E712 + func.count().filter( + Message.is_pending_conservation == True, # noqa: E712 + Message.is_conserved == False, # noqa: E712 + ).label("pending"), + func.max(Message.conserved_at).label("last_conserved_at"), + ).group_by(Message.tenant_id) + ) + tenant_rows = rows.all() + + # Carica nomi tenant + all_tenant_ids = [r.tenant_id for r in tenant_rows] + tenant_names: dict[uuid.UUID, tuple[str, str]] = {} + if all_tenant_ids: + t_rows = await db.execute( + select(Tenant.id, Tenant.name, Tenant.slug).where(Tenant.id.in_(all_tenant_ids)) + ) + for t in t_rows.all(): + tenant_names[t.id] = (t.name, t.slug) + + per_tenant: list[ConservationTenantBreakdown] = [] + agg_total = agg_conserved = agg_pending = 0 + agg_last: object = None + + for r in tenant_rows: + agg_total += r.total or 0 + agg_conserved += r.conserved or 0 + agg_pending += r.pending or 0 + if r.last_conserved_at and (agg_last is None or r.last_conserved_at > agg_last): + agg_last = r.last_conserved_at + + name, slug = tenant_names.get(r.tenant_id, ("(sconosciuto)", "")) + per_tenant.append(ConservationTenantBreakdown( + tenant_id=str(r.tenant_id), + tenant_name=name, + tenant_slug=slug, + stats=_build_conservation_stats( + total=r.total or 0, + conserved=r.conserved or 0, + pending=r.pending or 0, + last_conserved_at=r.last_conserved_at, + ), + )) + + aggregate = _build_conservation_stats( + total=agg_total, + conserved=agg_conserved, + pending=agg_pending, + last_conserved_at=agg_last, + ) + return ConservationStatsResponse(stats=aggregate, per_tenant=per_tenant) + + +@router.post( + "/conservation/trigger", + response_model=ConservationTriggerResult, + status_code=status.HTTP_202_ACCEPTED, + summary="Avvia manualmente il job di conservazione sostitutiva", + description=( + "Accoda immediatamente il job run_conservation nel worker arq. " + "Il job elabora tutti i tenant con messaggi pendenti di conservazione. " + "Utile per eseguire il ciclo di conservazione senza attendere il cron giornaliero. " + "Solo admin e super_admin possono avviarlo." + ), +) +async def trigger_conservation( + current_user: AdminUser, + db: DB, +) -> ConservationTriggerResult: + app_settings = get_app_settings() + try: + import urllib.parse + from arq.connections import RedisSettings, create_pool + + parsed = urllib.parse.urlparse(app_settings.redis_url) + redis_settings = RedisSettings( + host=parsed.hostname or "localhost", + port=parsed.port or 6379, + database=int(parsed.path.lstrip("/") or "0"), + password=parsed.password or None, + ) + redis = await create_pool(redis_settings) + job = await redis.enqueue_job("run_conservation") + await redis.aclose() + + job_id = job.job_id if job else None + return ConservationTriggerResult( + queued=True, + message="Job di conservazione accodato con successo. Verra' eseguito dal worker entro pochi secondi.", + job_id=job_id, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Impossibile accodare il job: {exc}", + ) + + @router.post( "/indexing/rescan", response_model=IndexingJobStatus, diff --git a/backend/app/schemas/tenant_settings.py b/backend/app/schemas/tenant_settings.py index aa5fdd7..134a2f5 100644 --- a/backend/app/schemas/tenant_settings.py +++ b/backend/app/schemas/tenant_settings.py @@ -131,3 +131,46 @@ class StartRescanRequest(BaseModel): "True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti." ), ) + + +# ─── Schemi conservazione sostitutiva ──────────────────────────────────────── + +class ConservationStats(BaseModel): + """Statistiche di conservazione sostitutiva per un tenant.""" + + total_messages: int + conserved: int # is_conserved=True + pending: int # is_pending_conservation=True AND is_conserved=False + not_queued: int # non ancora marcati per conservazione + coverage_pct: float # conserved / total * 100 + last_conserved_at: Optional[str] = None # ISO datetime del piu' recente messaggio conservato + + +class ConservationTenantBreakdown(BaseModel): + """Statistiche di un singolo tenant (usato da super_admin).""" + + tenant_id: str + tenant_name: str + tenant_slug: str + stats: ConservationStats + + +class ConservationStatsResponse(BaseModel): + """ + Risposta GET /settings/conservation/stats. + + - admin: stats.stats contiene le stat del suo tenant; per_tenant e' None + - super_admin con tenant_id: stats del tenant specificato; per_tenant e' None + - super_admin senza tenant_id: stats aggregate su tutti i tenant; per_tenant e' la lista per-tenant + """ + + stats: ConservationStats + per_tenant: Optional[list[ConservationTenantBreakdown]] = None + + +class ConservationTriggerResult(BaseModel): + """Risposta POST /settings/conservation/trigger.""" + + queued: bool + message: str + job_id: Optional[str] = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b463c8..6471433 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage' import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage' import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage' import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage' +import { ConservationPage } from '@/pages/Conservation/ConservationPage' /** * Routing principale dell'applicazione PEChub. @@ -113,6 +114,9 @@ export default function App() { {/* Tassonomia di classificazione multi-livello (N2) */} } /> + {/* Conservazione sostitutiva (admin) */} + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/settings.api.ts b/frontend/src/api/settings.api.ts index 912e087..98b9af9 100644 --- a/frontend/src/api/settings.api.ts +++ b/frontend/src/api/settings.api.ts @@ -2,12 +2,14 @@ * API client per le impostazioni del tenant. * * Endpoint: - * GET /api/v1/settings -> legge configurazione (admin) - * PUT /api/v1/settings -> aggiorna configurazione (admin) - * GET /api/v1/settings/indexing/stats -> statistiche indicizzazione - * GET /api/v1/settings/indexing/status -> stato job reindex - * POST /api/v1/settings/indexing/reindex -> avvia reindex - * DELETE /api/v1/settings/indexing/reindex -> cancella reindex + * GET /api/v1/settings -> legge configurazione (admin) + * PUT /api/v1/settings -> aggiorna configurazione (admin) + * GET /api/v1/settings/indexing/stats -> statistiche indicizzazione + * GET /api/v1/settings/indexing/status -> stato job reindex + * POST /api/v1/settings/indexing/reindex -> avvia reindex + * DELETE /api/v1/settings/indexing/reindex -> cancella reindex + * GET /api/v1/settings/conservation/stats -> statistiche conservazione + * POST /api/v1/settings/conservation/trigger -> avvia job conservazione manuale */ import { apiClient } from './client' @@ -83,6 +85,35 @@ export interface IndexingJobStatus { error: string | null } +// ─── Tipi conservazione sostitutiva ─────────────────────────────────────── + +export interface ConservationStats { + total_messages: number + conserved: number + pending: number + not_queued: number + coverage_pct: number // 0-100 + last_conserved_at: string | null // ISO datetime +} + +export interface ConservationTenantBreakdown { + tenant_id: string + tenant_name: string + tenant_slug: string + stats: ConservationStats +} + +export interface ConservationStatsResponse { + stats: ConservationStats + per_tenant: ConservationTenantBreakdown[] | null +} + +export interface ConservationTriggerResult { + queued: boolean + message: string + job_id: string | null +} + // ─── Client impostazioni generali ────────────────────────────────────────── export const settingsApi = { @@ -211,4 +242,32 @@ export const settingsApi = { ) return data }, + + // ── Conservazione sostitutiva ───────────────────────────────────────────── + + /** + * Restituisce le statistiche di conservazione sostitutiva. + * admin: stats del proprio tenant. + * super_admin senza tenantId: stats aggregate su tutti i tenant + per_tenant. + * super_admin con tenantId: stats del tenant specificato. + */ + getConservationStats: async (tenantId?: string): Promise => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.get( + '/settings/conservation/stats', + { params } + ) + return data + }, + + /** + * Avvia manualmente il job di conservazione sostitutiva. + * Il job verra' eseguito dal worker entro pochi secondi. + */ + triggerConservation: async (): Promise => { + const { data } = await apiClient.post( + '/settings/conservation/trigger' + ) + return data + }, } diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index a033617..22fd584 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -587,6 +587,7 @@ export function Sidebar() { { to: '/signatures', label: 'Firme automatiche', icon: PenLine }, { to: '/notifications', label: 'Notifiche', icon: Bell }, { to: '/audit-log', label: 'Audit Log', icon: ClipboardList }, + { to: '/conservation', label: 'Conservazione', icon: ShieldCheck }, ] as const).map((item) => ( 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 + const colorClass = { + teal: 'bg-teal-500', + green: 'bg-green-500', + amber: 'bg-amber-500', + red: 'bg-red-500', + gray: 'bg-gray-400', + }[color] + + return ( +
+
+
+ ) +} + +// ─── Card stat singola ──────────────────────────────────────────────────────── + +function StatCard({ + label, + value, + colorClass = 'text-gray-800', + bgClass = 'bg-gray-50', + borderClass = 'border-gray-200', +}: { + label: string + value: number + colorClass?: string + bgClass?: string + borderClass?: string +}) { + return ( +
+

+ {value.toLocaleString('it-IT')} +

+

{label}

+
+ ) +} + +// ─── Blocco statistiche (riusato per singolo tenant e aggregato) ────────────── + +function StatsBlock({ stats }: { stats: ConservationStats }) { + const coverageColor = stats.coverage_pct >= 90 ? 'teal' : stats.coverage_pct >= 60 ? 'amber' : 'red' + const coverageTextColor = + stats.coverage_pct >= 90 ? 'text-teal-700' : + stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700' + + return ( +
+ {/* Griglia statistiche */} +
+ + + 0 ? 'text-amber-700' : 'text-gray-500'} + bgClass={stats.pending > 0 ? 'bg-amber-50' : 'bg-gray-50'} + borderClass={stats.pending > 0 ? 'border-amber-200' : 'border-gray-200'} + /> + 0 ? 'text-gray-600' : 'text-gray-400'} + /> +
+ + {/* Barra copertura */} +
+
+ Copertura conservazione + + {stats.coverage_pct}% + +
+ + {stats.pending > 0 && ( +

+ {stats.pending.toLocaleString('it-IT')} messaggi in coda verranno elaborati al prossimo run. +

+ )} +
+ + {/* Ultima conservazione */} +
+ + + Ultima conservazione: {formatDatetime(stats.last_conserved_at)} + +
+
+ ) +} + +// ─── Pagina principale ──────────────────────────────────────────────────────── + +export function ConservationPage() { + const { user } = useAuth() + const isSuperAdmin = user?.role === 'super_admin' + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [triggering, setTriggering] = useState(false) + const [showTriggerConfirm, setShowTriggerConfirm] = useState(false) + const [perTenantExpanded, setPerTenantExpanded] = useState(false) + + // Selezione tenant per super_admin + const [tenantList, setTenantList] = useState([]) + // selectedTenantId vuoto = "tutti i tenant" (solo super_admin) + const [selectedTenantId, setSelectedTenantId] = useState('') + + // Carica lista tenant per super_admin + useEffect(() => { + if (isSuperAdmin) { + tenantsApi.list().then(setTenantList).catch(() => {}) + } + }, [isSuperAdmin]) + + const loadStats = async () => { + setLoading(true) + try { + const tenantId = isSuperAdmin && selectedTenantId ? selectedTenantId : undefined + const result = await settingsApi.getConservationStats(tenantId) + setData(result) + } catch { + toast.error('Errore durante il caricamento delle statistiche di conservazione') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadStats() + }, [selectedTenantId]) + + const handleTrigger = async () => { + setTriggering(true) + setShowTriggerConfirm(false) + try { + const result = await settingsApi.triggerConservation() + if (result.queued) { + toast.success(result.message) + // Ricarica le statistiche dopo qualche secondo + setTimeout(() => loadStats(), 3000) + } else { + toast.error('Il job non e\' stato accodato') + } + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + toast.error(msg ?? 'Errore durante l\'avvio della conservazione') + } finally { + setTriggering(false) + } + } + + if (!user) return null + + // Titolo sezione basato sulla selezione + const sectionTitle = isSuperAdmin && !selectedTenantId + ? 'Tutti i tenant (aggregato)' + : isSuperAdmin && selectedTenantId + ? (tenantList.find((t) => t.id === selectedTenantId)?.name ?? 'Tenant selezionato') + : 'Il tuo tenant' + + return ( +
+ + {/* ── Intestazione ── */} +
+
+ +
+
+

Conservazione Sostitutiva

+

+ Statistiche e gestione del ciclo di conservazione documentale AgID +

+
+
+ + {/* ── Selector tenant (solo super_admin) ── */} + {isSuperAdmin && tenantList.length > 0 && ( + +
+ + + Selezione Tenant (Super Admin) + +
+ +
+ )} + + {/* ── Card statistiche ── */} + +
+
+ +

+ Statistiche — {sectionTitle} +

+
+ +
+ + {loading ? ( +
+ + Caricamento statistiche... +
+ ) : data ? ( + + ) : ( +

Nessun dato disponibile

+ )} +
+ + {/* ── Tabella per-tenant (solo super_admin senza selezione specifica) ── */} + {isSuperAdmin && !selectedTenantId && data?.per_tenant && data.per_tenant.length > 0 && ( + + + + {perTenantExpanded && ( +
+ + + + + + + + + + + + + {data.per_tenant.map((t) => ( + + + + + + + + + ))} + +
TenantTotaliConservatiIn codaNon accodatiCopertura
+

{t.tenant_name}

+

{t.tenant_slug}

+
+ {t.stats.total_messages.toLocaleString('it-IT')} + + {t.stats.conserved.toLocaleString('it-IT')} + 0 ? 'text-amber-600' : 'text-gray-400'}`}> + {t.stats.pending.toLocaleString('it-IT')} + + {t.stats.not_queued.toLocaleString('it-IT')} + + = 90 ? 'text-teal-700' : + t.stats.coverage_pct >= 60 ? 'text-amber-600' : 'text-red-600' + }`}> + {t.stats.coverage_pct}% + +
+
+ )} +
+ )} + + {/* ── Card azione manuale ── */} + +
+ +

+ Azione Manuale +

+
+ +
+

+ Il ciclo di conservazione viene eseguito automaticamente ogni giorno alle 18:00 (ora italiana). + Usa questo pulsante per avviarlo immediatamente senza attendere il cron giornaliero. +

+

+ Il job elabora tutti i tenant con messaggi pendenti (is_pending_conservation=TRUE) + e invia i pacchetti SIP al conservatore configurato. I messaggi gia' conservati + (is_conserved=TRUE) vengono saltati. +

+
+ + {/* Dialogo conferma */} + {showTriggerConfirm && ( +
+ +
+

Conferma avvio conservazione

+

+ Il job verra' accodato immediatamente e il worker lo eseguira' entro pochi secondi. + Vengono processati tutti i tenant con messaggi pendenti. +

+
+ + · + +
+
+
+ )} + + +
+ + {/* ── Info ciclo cron ── */} +
+

Come funziona il ciclo di conservazione

+

+ Il worker arq esegue automaticamente run_conservation ogni giorno alle 18:00 (ora italiana). + Per ogni messaggio marcato da conservare, viene costruito un pacchetto SIP BagIt completo + (EML principale + allegati + ricevute PEC) e inviato al conservatore configurato (es. Aeterna). + Dopo il successo, il messaggio viene marcato is_conserved=TRUE. + In caso di errore rimane in coda e verra' ritentato al prossimo run giornaliero. +

+

+ + La conservazione e' idempotente: i messaggi gia' conservati non vengono rielaborati. +

+
+
+ ) +}