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
+270
View File
@@ -0,0 +1,270 @@
"""
Service per la gestione dei Fascicoli (fascicolazione pratiche).
"""
import uuid
from datetime import datetime
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())
+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),
@@ -0,0 +1,125 @@
"""
Servizio CRUD per i preset di permessi (sottoruoli nominati).
Admin e supervisor possono creare, modificare ed eliminare preset per il proprio tenant.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.models.permission_preset import PermissionPreset
from app.models.user import User
from app.schemas.permission_preset import PermissionPresetCreate, PermissionPresetUpdate
class PermissionPresetService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
def _require_supervisor_or_admin(self, user: User) -> None:
if not user.is_supervisor_or_admin:
raise ForbiddenError("Solo amministratori e supervisori possono gestire i preset")
async def list_presets(self, tenant_id: uuid.UUID) -> list[PermissionPreset]:
"""Ritorna tutti i preset del tenant ordinati per nome."""
result = await self.db.execute(
select(PermissionPreset)
.where(PermissionPreset.tenant_id == tenant_id)
.order_by(PermissionPreset.name)
)
return list(result.scalars().all())
async def get_preset(self, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> PermissionPreset:
"""Recupera un preset per ID verificando che appartenga al tenant."""
preset = await self.db.get(PermissionPreset, preset_id)
if not preset or preset.tenant_id != tenant_id:
raise NotFoundError("preset")
return preset
async def create_preset(
self,
tenant_id: uuid.UUID,
data: PermissionPresetCreate,
created_by: User,
) -> PermissionPreset:
"""Crea un nuovo preset. Il nome deve essere unico per tenant."""
self._require_supervisor_or_admin(created_by)
# Verifica unicita' nome
existing = await self.db.execute(
select(PermissionPreset).where(
PermissionPreset.tenant_id == tenant_id,
PermissionPreset.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
preset = PermissionPreset(
tenant_id=tenant_id,
name=data.name,
description=data.description,
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
can_conserve=data.can_conserve,
created_by=created_by.id,
)
self.db.add(preset)
await self.db.flush()
await self.db.refresh(preset)
return preset
async def update_preset(
self,
preset_id: uuid.UUID,
tenant_id: uuid.UUID,
data: PermissionPresetUpdate,
updated_by: User,
) -> PermissionPreset:
"""Aggiorna un preset esistente."""
self._require_supervisor_or_admin(updated_by)
preset = await self.get_preset(preset_id, tenant_id)
# Verifica unicita' nome se cambia
if data.name is not None and data.name != preset.name:
existing = await self.db.execute(
select(PermissionPreset).where(
PermissionPreset.tenant_id == tenant_id,
PermissionPreset.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
preset.name = data.name
if data.description is not None:
preset.description = data.description
if data.can_read is not None:
preset.can_read = data.can_read
if data.can_send is not None:
preset.can_send = data.can_send
if data.can_manage is not None:
preset.can_manage = data.can_manage
if data.can_conserve is not None:
preset.can_conserve = data.can_conserve
await self.db.flush()
await self.db.refresh(preset)
return preset
async def delete_preset(
self,
preset_id: uuid.UUID,
tenant_id: uuid.UUID,
deleted_by: User,
) -> None:
"""Elimina un preset."""
self._require_supervisor_or_admin(deleted_by)
preset = await self.get_preset(preset_id, tenant_id)
await self.db.delete(preset)
await self.db.flush()
+5 -5
View File
@@ -142,10 +142,10 @@ class PermissionService:
) -> MailboxPermission:
"""
Crea o aggiorna un permesso utente su una casella.
Solo admin può gestire i permessi.
Admin e supervisor possono gestire i permessi.
"""
if not granted_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
if not granted_by.is_supervisor_or_admin:
raise ForbiddenError("Solo amministratori e supervisori possono gestire i permessi")
# Verifica che casella e utente appartengano al tenant
mailbox = await self.db.get(Mailbox, mailbox_id)
@@ -190,8 +190,8 @@ class PermissionService:
user_id: uuid.UUID,
revoked_by: User,
) -> None:
if not revoked_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
if not revoked_by.is_supervisor_or_admin:
raise ForbiddenError("Solo amministratori e supervisori possono revocare i permessi")
result = await self.db.execute(
delete(MailboxPermission).where(
+54 -3
View File
@@ -190,11 +190,44 @@ class RoutingRuleService:
return False
for cond in conditions:
field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False
# has_label è una condizione speciale: verifica presenza di MessageLabel
if cond.field == "has_label":
if not await self._condition_has_label(message, cond.operator, cond.value):
return False
else:
field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False
return True
async def _condition_has_label(
self, message: Message, operator: str, value: str
) -> bool:
"""
Verifica se il messaggio ha (o non ha) una specifica etichetta.
operator 'equals' / 'contains' → True se il messaggio HA la label con UUID=value
operator 'not_contains' → True se il messaggio NON HA la label
"""
try:
label_id = uuid.UUID(value)
except (ValueError, AttributeError):
return False
existing = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id == message.id,
MessageLabel.label_id == label_id,
)
)
has_it = existing.scalar_one_or_none() is not None
if operator in ("equals", "contains", "starts_with", "ends_with"):
return has_it
elif operator == "not_contains":
return not has_it
return has_it
def _get_field_value(self, message: Message, field: str) -> str:
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
if field == "from_address":
@@ -207,6 +240,11 @@ class RoutingRuleService:
return str(message.mailbox_id)
elif field == "pec_type":
return message.pec_type or ""
# Rischio e Riservatezza (N3)
elif field == "risk_level":
return message.risk_level or ""
elif field == "confidentiality":
return message.confidentiality or ""
return ""
def _evaluate_condition(
@@ -241,12 +279,25 @@ class RoutingRuleService:
try:
if action.action_type == "apply_label" and action.action_value:
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "apply_taxonomy" and action.action_value:
# Applica un nodo tassonomico: identico a apply_label
# Il nodo è una Label con parent_id valorizzato (Processo o Classificazione)
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "mark_read":
message.is_read = True
elif action.action_type == "mark_starred":
message.is_starred = True
elif action.action_type == "notify_webhook" and action.action_value:
await self._action_notify_webhook(message, action.action_value)
# Rischio e Riservatezza (N3)
elif action.action_type == "set_risk_level" and action.action_value:
valid_levels = {"low", "medium", "high", "critical"}
if action.action_value in valid_levels:
message.risk_level = action.action_value
elif action.action_type == "set_confidentiality" and action.action_value:
valid_levels = {"public", "internal", "confidential", "secret"}
if action.action_value in valid_levels:
message.confidentiality = action.action_value
except Exception:
# Le azioni non devono interrompere il flusso principale
pass