""" Schema Pydantic per TenantSettings – lettura e aggiornamento impostazioni tenant. Include schemi per il modulo di indicizzazione full-text. """ import uuid from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, Field, 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 # ─── Schemi indicizzazione full-text ────────────────────────────────────────── class IndexingStats(BaseModel): """Statistiche di copertura dell'indicizzazione per un tenant.""" total_messages: int indexed_messages: int unindexed_messages: int coverage_pct: float # percentuale messaggi con search_vector != NULL attachments_total: int # allegati PDF/DOCX totali attachments_extracted: int # allegati con testo estratto attachments_pct: float # percentuale allegati con testo estratto class IndexingJobStatus(BaseModel): """Stato di un job di reindex in corso o completato.""" status: str # idle | running | completed | failed | cancelled mode: Optional[str] = None # full | differential total: int = 0 processed: int = 0 progress_pct: float = 0.0 started_at: Optional[str] = None # ISO datetime finished_at: Optional[str] = None # ISO datetime started_by: Optional[str] = None # email utente che ha avviato il job elapsed_seconds: Optional[int] = None is_stale: bool = False # True se running da piu' di STALE_THRESHOLD_HOURS error: Optional[str] = None class StartReindexRequest(BaseModel): """Body per POST /settings/indexing/reindex.""" mode: Literal["full", "differential"] = "differential" class StartRescanRequest(BaseModel): """Body per POST /settings/indexing/rescan.""" force: bool = Field( default=False, description=( "False (default): estrae solo allegati con extracted_text NULL. " "True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti." ), )