Fascicoli+Tassonomia+permessi

This commit is contained in:
2026-06-17 21:47:46 +02:00
parent e31676d22e
commit 3fd3c72f06
42 changed files with 4554 additions and 99 deletions
+116 -31
View File
@@ -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),