Fascicoli+Tassonomia+permessi
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
"""
|
||||
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 ConflictError, NotFoundError
|
||||
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, LabelUpdate
|
||||
from app.schemas.label import LabelCreate, LabelTreeResponse, LabelUpdate
|
||||
|
||||
# Profondità massima della tassonomia (0=Ambito, 1=Processo, 2=Classificazione)
|
||||
MAX_TAXONOMY_DEPTH = 2
|
||||
|
||||
|
||||
class LabelService:
|
||||
@@ -20,6 +25,7 @@ class LabelService:
|
||||
# ─── 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)
|
||||
@@ -27,6 +33,56 @@ class LabelService:
|
||||
)
|
||||
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(
|
||||
@@ -39,21 +95,52 @@ class LabelService:
|
||||
raise NotFoundError(f"Tag {label_id} non trovato")
|
||||
return label
|
||||
|
||||
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
|
||||
# Verifica unicità
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
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,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
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()
|
||||
@@ -66,21 +153,29 @@ class LabelService:
|
||||
label = await self.get_label(tenant_id, label_id)
|
||||
|
||||
if data.name is not None:
|
||||
# Verifica unicità del nuovo nome
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
Label.id != label_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
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
|
||||
@@ -113,7 +208,6 @@ class LabelService:
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> list[Label]:
|
||||
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
|
||||
# Verifica che i label appartengano al tenant
|
||||
valid_ids: set[uuid.UUID] = set()
|
||||
if label_ids:
|
||||
result = await self.db.execute(
|
||||
@@ -124,12 +218,9 @@ class LabelService:
|
||||
)
|
||||
valid_ids = {lbl.id for lbl in result.scalars().all()}
|
||||
|
||||
# Rimuovi tutti i tag esistenti dal messaggio
|
||||
await self.db.execute(
|
||||
delete(MessageLabel).where(MessageLabel.message_id == message_id)
|
||||
)
|
||||
|
||||
# Aggiungi i nuovi tag validi
|
||||
for lbl_id in valid_ids:
|
||||
self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id))
|
||||
|
||||
@@ -146,7 +237,6 @@ class LabelService:
|
||||
if not label_ids:
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
# Verifica appartenenza al tenant
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
@@ -155,7 +245,6 @@ class LabelService:
|
||||
)
|
||||
valid_labels = list(result.scalars().all())
|
||||
|
||||
# Carica tag esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel.label_id).where(
|
||||
MessageLabel.message_id == message_id
|
||||
@@ -199,7 +288,6 @@ class LabelService:
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica label del tenant
|
||||
lbl_result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
@@ -208,7 +296,6 @@ class LabelService:
|
||||
)
|
||||
valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()]
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
@@ -220,7 +307,6 @@ class LabelService:
|
||||
if not valid_label_ids or not valid_message_ids:
|
||||
return 0
|
||||
|
||||
# Carica coppie esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel).where(
|
||||
MessageLabel.message_id.in_(valid_message_ids),
|
||||
@@ -249,7 +335,6 @@ class LabelService:
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
|
||||
Reference in New Issue
Block a user