Conservazione in Sidebar
This commit is contained in:
@@ -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=<uuid>: 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user