""" Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi. Esteso con supporto alla tassonomia gerarchica (Feature N2). """ import uuid from typing import Optional from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError from app.models.label import Label, MessageLabel from app.models.message import Message from app.schemas.label import LabelCreate, LabelTreeResponse, LabelUpdate # Profondità massima della tassonomia (0=Ambito, 1=Processo, 2=Classificazione) MAX_TAXONOMY_DEPTH = 2 class LabelService: def __init__(self, db: AsyncSession): self.db = db # ─── CRUD Label ─────────────────────────────────────────────────────────── async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]: """Lista flat di tutte le label del tenant (label piatte + nodi tassonomici).""" result = await self.db.execute( select(Label) .where(Label.tenant_id == tenant_id) .order_by(Label.name) ) return list(result.scalars().all()) async def list_root_labels(self, tenant_id: uuid.UUID) -> list[Label]: """Lista solo le label radice (parent_id IS NULL).""" result = await self.db.execute( select(Label) .where(Label.tenant_id == tenant_id, Label.parent_id.is_(None)) .order_by(Label.name) ) return list(result.scalars().all()) async def get_label_tree(self, tenant_id: uuid.UUID) -> list[LabelTreeResponse]: """ Restituisce la tassonomia come albero annidato. Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ] Include anche label piatte (parent_id=None) come nodi radice. """ result = await self.db.execute( select(Label) .where(Label.tenant_id == tenant_id) .order_by(Label.name) ) all_labels = list(result.scalars().all()) # Costruisce un dizionario id -> LabelTreeResponse nodes: dict[uuid.UUID, LabelTreeResponse] = {} for lbl in all_labels: nodes[lbl.id] = LabelTreeResponse( id=lbl.id, tenant_id=lbl.tenant_id, name=lbl.name, color=lbl.color, parent_id=lbl.parent_id, description=lbl.description, children=[], ) # Collega figli ai genitori e raccoglie le radici roots: list[LabelTreeResponse] = [] for lbl in all_labels: node = nodes[lbl.id] if lbl.parent_id is None: roots.append(node) elif lbl.parent_id in nodes: nodes[lbl.parent_id].children.append(node) else: # Orfano (parent rimosso): trattato come radice roots.append(node) return roots async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label: result = await self.db.execute( select(Label).where( Label.id == label_id, Label.tenant_id == tenant_id, ) ) label = result.scalar_one_or_none() if not label: raise NotFoundError(f"Tag {label_id} non trovato") return label async def _get_depth(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> int: """ Calcola la profondità di una label nell'albero tassonomico. 0 = radice (Ambito), 1 = Processo, 2 = Classificazione. """ depth = 0 current_id: Optional[uuid.UUID] = label_id visited: set[uuid.UUID] = set() while current_id and depth <= MAX_TAXONOMY_DEPTH + 1: if current_id in visited: break # Ciclo (non dovrebbe accadere ma sicurezza) visited.add(current_id) row = await self.db.execute( select(Label.parent_id).where( Label.id == current_id, Label.tenant_id == tenant_id, ) ) parent_id = row.scalar_one_or_none() if parent_id is None: break current_id = parent_id depth += 1 return depth async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label: # Valida il parent (se specificato) if data.parent_id is not None: await self.get_label(tenant_id, data.parent_id) # 404 se non esiste parent_depth = await self._get_depth(tenant_id, data.parent_id) if parent_depth >= MAX_TAXONOMY_DEPTH: raise ValidationError( f"La tassonomia supporta al massimo {MAX_TAXONOMY_DEPTH + 1} livelli " "(Ambito > Processo > Classificazione). " "Il nodo padre ha gia' raggiunto la profondita' massima." ) label = Label( tenant_id=tenant_id, name=data.name, color=data.color, parent_id=data.parent_id, description=data.description, ) self.db.add(label) await self.db.commit() await self.db.refresh(label) return label async def update_label( self, tenant_id: uuid.UUID, label_id: uuid.UUID, data: LabelUpdate ) -> Label: label = await self.get_label(tenant_id, label_id) if data.name is not None: label.name = data.name if data.color is not None: label.color = data.color if data.description is not None: label.description = data.description if data.parent_id is not None: # Impedisce cicli: il nuovo parent non può essere il nodo stesso o un suo discendente if data.parent_id == label_id: raise ValidationError("Un nodo non puo' essere genitore di se stesso") await self.get_label(tenant_id, data.parent_id) parent_depth = await self._get_depth(tenant_id, data.parent_id) if parent_depth >= MAX_TAXONOMY_DEPTH: raise ValidationError( "Il nodo padre ha gia' raggiunto la profondita' massima consentita." ) label.parent_id = data.parent_id elif data.parent_id is None and "parent_id" in (data.model_fields_set or set()): # Esplicita rimozione del parent (promozione a radice) label.parent_id = None await self.db.commit() await self.db.refresh(label) return label async def delete_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> None: label = await self.get_label(tenant_id, label_id) await self.db.delete(label) await self.db.commit() # ─── Assegnazione tag a singolo messaggio ───────────────────────────────── async def get_message_labels( self, message_id: uuid.UUID, tenant_id: uuid.UUID ) -> list[Label]: result = await self.db.execute( select(Label) .join(MessageLabel, Label.id == MessageLabel.label_id) .where( MessageLabel.message_id == message_id, Label.tenant_id == tenant_id, ) .order_by(Label.name) ) return list(result.scalars().all()) async def set_message_labels( self, message_id: uuid.UUID, tenant_id: uuid.UUID, label_ids: list[uuid.UUID], ) -> list[Label]: """Sostituisce tutti i tag di un messaggio con quelli indicati.""" valid_ids: set[uuid.UUID] = set() if label_ids: result = await self.db.execute( select(Label).where( Label.id.in_(label_ids), Label.tenant_id == tenant_id, ) ) valid_ids = {lbl.id for lbl in result.scalars().all()} await self.db.execute( delete(MessageLabel).where(MessageLabel.message_id == message_id) ) for lbl_id in valid_ids: self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id)) await self.db.commit() return await self.get_message_labels(message_id, tenant_id) async def add_message_labels( self, message_id: uuid.UUID, tenant_id: uuid.UUID, label_ids: list[uuid.UUID], ) -> list[Label]: """Aggiunge tag a un messaggio senza rimuovere quelli esistenti.""" if not label_ids: return await self.get_message_labels(message_id, tenant_id) result = await self.db.execute( select(Label).where( Label.id.in_(label_ids), Label.tenant_id == tenant_id, ) ) valid_labels = list(result.scalars().all()) existing_result = await self.db.execute( select(MessageLabel.label_id).where( MessageLabel.message_id == message_id ) ) existing_ids = set(existing_result.scalars().all()) for lbl in valid_labels: if lbl.id not in existing_ids: self.db.add(MessageLabel(message_id=message_id, label_id=lbl.id)) await self.db.commit() return await self.get_message_labels(message_id, tenant_id) async def remove_message_labels( self, message_id: uuid.UUID, tenant_id: uuid.UUID, label_ids: list[uuid.UUID], ) -> list[Label]: """Rimuove specifici tag da un messaggio.""" if label_ids: await self.db.execute( delete(MessageLabel).where( MessageLabel.message_id == message_id, MessageLabel.label_id.in_(label_ids), ) ) await self.db.commit() return await self.get_message_labels(message_id, tenant_id) # ─── Azioni bulk ────────────────────────────────────────────────────────── async def bulk_add_labels( self, message_ids: list[uuid.UUID], tenant_id: uuid.UUID, label_ids: list[uuid.UUID], ) -> int: """Aggiunge tag a più messaggi in blocco.""" if not label_ids or not message_ids: return 0 lbl_result = await self.db.execute( select(Label).where( Label.id.in_(label_ids), Label.tenant_id == tenant_id, ) ) valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()] msg_result = await self.db.execute( select(Message.id).where( Message.id.in_(message_ids), Message.tenant_id == tenant_id, ) ) valid_message_ids = list(msg_result.scalars().all()) if not valid_label_ids or not valid_message_ids: return 0 existing_result = await self.db.execute( select(MessageLabel).where( MessageLabel.message_id.in_(valid_message_ids), MessageLabel.label_id.in_(valid_label_ids), ) ) existing_pairs = { (ml.message_id, ml.label_id) for ml in existing_result.scalars().all() } for msg_id in valid_message_ids: for lbl_id in valid_label_ids: if (msg_id, lbl_id) not in existing_pairs: self.db.add(MessageLabel(message_id=msg_id, label_id=lbl_id)) await self.db.commit() return len(valid_message_ids) async def bulk_remove_labels( self, message_ids: list[uuid.UUID], tenant_id: uuid.UUID, label_ids: list[uuid.UUID], ) -> int: """Rimuove tag da più messaggi in blocco.""" if not label_ids or not message_ids: return 0 msg_result = await self.db.execute( select(Message.id).where( Message.id.in_(message_ids), Message.tenant_id == tenant_id, ) ) valid_message_ids = list(msg_result.scalars().all()) if not valid_message_ids: return 0 await self.db.execute( delete(MessageLabel).where( MessageLabel.message_id.in_(valid_message_ids), MessageLabel.label_id.in_(label_ids), ) ) await self.db.commit() return len(valid_message_ids)