""" Service per la gestione dei Fascicoli (fascicolazione pratiche). """ import uuid from datetime import datetime, timedelta, timezone from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError from app.models.fascicolo import Fascicolo, FascicoloMessage from app.models.message import Message from app.models.user import User from app.schemas.fascicolo import FascicoloCreate, FascicoloUpdate class FascicoloService: def __init__(self, db: AsyncSession): self.db = db # ─── CRUD Fascicolo ─────────────────────────────────────────────────────── async def list_fascicoli( self, tenant_id: uuid.UUID, stato: str | None = None, responsabile_id: uuid.UUID | None = None, search: str | None = None, ) -> list[tuple[Fascicolo, int]]: """ Restituisce lista di (Fascicolo, message_count) con filtri opzionali. """ # Subquery per il conteggio messaggi count_sub = ( select( FascicoloMessage.fascicolo_id, func.count(FascicoloMessage.message_id).label("cnt"), ) .group_by(FascicoloMessage.fascicolo_id) .subquery() ) stmt = ( select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count")) .outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id) .where(Fascicolo.tenant_id == tenant_id) .order_by(Fascicolo.updated_at.desc()) ) if stato: stmt = stmt.where(Fascicolo.stato == stato) if responsabile_id: stmt = stmt.where(Fascicolo.responsabile_id == responsabile_id) if search: pattern = f"%{search}%" stmt = stmt.where( Fascicolo.titolo.ilike(pattern) | Fascicolo.numero_pratica.ilike(pattern) | Fascicolo.categoria.ilike(pattern) ) result = await self.db.execute(stmt) return list(result.all()) async def get_fascicolo( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID ) -> tuple[Fascicolo, int]: """Restituisce (Fascicolo, message_count) o solleva NotFoundError.""" count_sub = ( select( FascicoloMessage.fascicolo_id, func.count(FascicoloMessage.message_id).label("cnt"), ) .group_by(FascicoloMessage.fascicolo_id) .subquery() ) result = await self.db.execute( select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count")) .outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id) .where( Fascicolo.id == fascicolo_id, Fascicolo.tenant_id == tenant_id, ) ) row = result.one_or_none() if not row: raise NotFoundError(f"Fascicolo {fascicolo_id} non trovato") return row async def create_fascicolo( self, tenant_id: uuid.UUID, data: FascicoloCreate, created_by: uuid.UUID, ) -> tuple[Fascicolo, int]: fascicolo = Fascicolo( id=uuid.uuid4(), tenant_id=tenant_id, titolo=data.titolo, numero_pratica=data.numero_pratica, stato=data.stato or "aperto", categoria=data.categoria, responsabile_id=data.responsabile_id, scadenza=data.scadenza, note=data.note, created_by=created_by, ) self.db.add(fascicolo) await self.db.commit() await self.db.refresh(fascicolo) return fascicolo, 0 async def update_fascicolo( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID, data: FascicoloUpdate, current_user_id: uuid.UUID, is_admin: bool, ) -> tuple[Fascicolo, int]: fascicolo, count = await self.get_fascicolo(tenant_id, fascicolo_id) # Solo admin o il creatore possono modificare if not is_admin and fascicolo.created_by != current_user_id: raise ForbiddenError("Solo il creatore o un amministratore puo' modificare questo fascicolo") if data.titolo is not None: fascicolo.titolo = data.titolo if data.numero_pratica is not None: fascicolo.numero_pratica = data.numero_pratica if data.stato is not None: fascicolo.stato = data.stato if data.categoria is not None: fascicolo.categoria = data.categoria if data.responsabile_id is not None: fascicolo.responsabile_id = data.responsabile_id if data.scadenza is not None: fascicolo.scadenza = data.scadenza if data.note is not None: fascicolo.note = data.note fascicolo.updated_at = datetime.utcnow() await self.db.commit() await self.db.refresh(fascicolo) return fascicolo, count async def delete_fascicolo( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID, ) -> None: fascicolo, _ = await self.get_fascicolo(tenant_id, fascicolo_id) await self.db.delete(fascicolo) await self.db.commit() # ─── Messaggi nel fascicolo ─────────────────────────────────────────────── async def get_fascicolo_messages( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID, ) -> list[tuple[Message, datetime]]: """ Restituisce lista di (Message, added_at) ordinati per added_at desc. """ # Verifica fascicolo appartiene al tenant await self.get_fascicolo(tenant_id, fascicolo_id) result = await self.db.execute( select(Message, FascicoloMessage.added_at) .join(FascicoloMessage, Message.id == FascicoloMessage.message_id) .where( FascicoloMessage.fascicolo_id == fascicolo_id, Message.tenant_id == tenant_id, ) .order_by(FascicoloMessage.added_at.desc()) ) return list(result.all()) async def add_messages( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID, message_ids: list[uuid.UUID], added_by: uuid.UUID, ) -> int: """ Aggiunge messaggi al fascicolo. Ignora duplicati e messaggi non del tenant. Restituisce il numero di messaggi aggiunti. """ await self.get_fascicolo(tenant_id, fascicolo_id) # Verifica che i messaggi appartengano al tenant msg_result = await self.db.execute( select(Message.id).where( Message.id.in_(message_ids), Message.tenant_id == tenant_id, ) ) valid_ids = list(msg_result.scalars().all()) if not valid_ids: return 0 # Carica associazioni esistenti per evitare duplicati existing_result = await self.db.execute( select(FascicoloMessage.message_id).where( FascicoloMessage.fascicolo_id == fascicolo_id, FascicoloMessage.message_id.in_(valid_ids), ) ) existing_ids = set(existing_result.scalars().all()) added = 0 for msg_id in valid_ids: if msg_id not in existing_ids: self.db.add( FascicoloMessage( fascicolo_id=fascicolo_id, message_id=msg_id, added_by=added_by, ) ) added += 1 if added: await self.db.commit() return added async def remove_messages( self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID, message_ids: list[uuid.UUID], ) -> int: """Rimuove messaggi dal fascicolo. Restituisce il numero rimosso.""" await self.get_fascicolo(tenant_id, fascicolo_id) result = await self.db.execute( delete(FascicoloMessage).where( FascicoloMessage.fascicolo_id == fascicolo_id, FascicoloMessage.message_id.in_(message_ids), ).returning(FascicoloMessage.message_id) ) removed = len(result.fetchall()) if removed: await self.db.commit() return removed # ─── Fascicoli di un messaggio ──────────────────────────────────────────── async def get_message_fascicoli( self, tenant_id: uuid.UUID, message_id: uuid.UUID, ) -> list[Fascicolo]: """Restituisce i fascicoli a cui appartiene un messaggio.""" result = await self.db.execute( select(Fascicolo) .join(FascicoloMessage, Fascicolo.id == FascicoloMessage.fascicolo_id) .where( FascicoloMessage.message_id == message_id, Fascicolo.tenant_id == tenant_id, ) .order_by(Fascicolo.titolo) ) return list(result.scalars().all()) # ─── Scadenzario fascicoli ──────────────────────────────────────────────── async def list_fascicoli_scadenzario( self, tenant_id: uuid.UUID, days_ahead: int = 30, include_overdue: bool = True, ) -> list[tuple[Fascicolo, int, bool]]: """ Restituisce lista di (Fascicolo, message_count, is_overdue) per lo scadenzario. Filtra fascicoli con scadenza impostata nel range richiesto. Ordinati: scaduti prima (ASC scadenza), poi futuri (ASC scadenza). """ now = datetime.now(timezone.utc) future_limit = now + timedelta(days=days_ahead) count_sub = ( select( FascicoloMessage.fascicolo_id, func.count(FascicoloMessage.message_id).label("cnt"), ) .group_by(FascicoloMessage.fascicolo_id) .subquery() ) stmt = ( select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count")) .outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id) .where( Fascicolo.tenant_id == tenant_id, Fascicolo.scadenza.is_not(None), ) .order_by(Fascicolo.scadenza.asc()) ) if include_overdue: # Scaduti (qualsiasi data passata) + futuri fino al limite stmt = stmt.where(Fascicolo.scadenza <= future_limit) else: # Solo scadenze future entro il limite stmt = stmt.where( Fascicolo.scadenza > now, Fascicolo.scadenza <= future_limit, ) result = await self.db.execute(stmt) rows = result.all() return [ (fascicolo, int(cnt), fascicolo.scadenza < now if fascicolo.scadenza else False) for fascicolo, cnt in rows ]