Fascicoli+Tassonomia+permessi
This commit is contained in:
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user