Files
PecHub/backend/app/services/label_service.py
T

357 lines
12 KiB
Python

"""
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)