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
+225
View File
@@ -0,0 +1,225 @@
"""
Router Fascicoli — fascicolazione pratiche.
Endpoint:
- GET /fascicoli lista fascicoli (con filtri)
- POST /fascicoli crea un fascicolo
- GET /fascicoli/{id} dettaglio fascicolo
- PATCH /fascicoli/{id} modifica fascicolo
- DELETE /fascicoli/{id} elimina fascicolo (solo admin)
- GET /fascicoli/{id}/messages messaggi del fascicolo
- POST /fascicoli/{id}/messages aggiungi messaggi al fascicolo
- DELETE /fascicoli/{id}/messages rimuovi messaggi dal fascicolo
- GET /messages/{message_id}/fascicoli fascicoli di un messaggio
Permessi:
- Tutti gli utenti autenticati possono creare fascicoli.
- PATCH: creatore o admin.
- DELETE fascicolo: solo admin.
- Operazioni su messaggi: utente con accesso al tenant.
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Query, status
from sqlalchemy import select
from app.core.exceptions import ForbiddenError, NotFoundError
from app.dependencies import AdminUser, CurrentUser, DB
from app.models.message import Message
from app.schemas.fascicolo import (
FascicoloAddMessagesRequest,
FascicoloCreate,
FascicoloMessageItem,
FascicoloRemoveMessagesRequest,
FascicoloResponse,
FascicoloUpdate,
MessageFascicoloSummary,
)
from app.services.fascicolo_service import FascicoloService
router = APIRouter(tags=["Fascicoli"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _to_response(fascicolo, message_count: int) -> FascicoloResponse:
resp = FascicoloResponse.model_validate(fascicolo)
resp.message_count = int(message_count)
return resp
# ─── CRUD Fascicolo ───────────────────────────────────────────────────────────
@router.get("/fascicoli", response_model=list[FascicoloResponse])
async def list_fascicoli(
current_user: CurrentUser,
db: DB,
stato: Optional[str] = Query(None, pattern=r"^(aperto|chiuso|archiviato)$"),
responsabile_id: Optional[uuid.UUID] = Query(None),
search: Optional[str] = Query(None, max_length=200),
) -> list[FascicoloResponse]:
"""Elenca i fascicoli del tenant con filtri opzionali."""
svc = FascicoloService(db)
rows = await svc.list_fascicoli(
current_user.tenant_id,
stato=stato,
responsabile_id=responsabile_id,
search=search,
)
return [_to_response(f, cnt) for f, cnt in rows]
@router.post("/fascicoli", response_model=FascicoloResponse, status_code=status.HTTP_201_CREATED)
async def create_fascicolo(
data: FascicoloCreate,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""Crea un nuovo fascicolo."""
svc = FascicoloService(db)
fascicolo, cnt = await svc.create_fascicolo(
current_user.tenant_id, data, created_by=current_user.id
)
return _to_response(fascicolo, cnt)
@router.get("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
async def get_fascicolo(
fascicolo_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""Restituisce il dettaglio di un fascicolo."""
svc = FascicoloService(db)
fascicolo, cnt = await svc.get_fascicolo(current_user.tenant_id, fascicolo_id)
return _to_response(fascicolo, cnt)
@router.patch("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
async def update_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloUpdate,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""
Modifica un fascicolo.
Solo il creatore o un amministratore possono modificarlo.
"""
svc = FascicoloService(db)
fascicolo, cnt = await svc.update_fascicolo(
current_user.tenant_id,
fascicolo_id,
data,
current_user_id=current_user.id,
is_admin=current_user.is_admin,
)
return _to_response(fascicolo, cnt)
@router.delete("/fascicoli/{fascicolo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fascicolo(
fascicolo_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un fascicolo (solo admin). I messaggi non vengono eliminati."""
svc = FascicoloService(db)
await svc.delete_fascicolo(current_user.tenant_id, fascicolo_id)
# ─── Messaggi del fascicolo ───────────────────────────────────────────────────
@router.get("/fascicoli/{fascicolo_id}/messages", response_model=list[FascicoloMessageItem])
async def get_fascicolo_messages(
fascicolo_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[FascicoloMessageItem]:
"""Restituisce i messaggi collegati al fascicolo."""
svc = FascicoloService(db)
rows = await svc.get_fascicolo_messages(current_user.tenant_id, fascicolo_id)
items = []
for msg, added_at in rows:
item = FascicoloMessageItem(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
added_at=added_at,
)
items.append(item)
return items
@router.post("/fascicoli/{fascicolo_id}/messages", response_model=dict)
async def add_messages_to_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloAddMessagesRequest,
current_user: CurrentUser,
db: DB,
) -> dict:
"""
Aggiunge messaggi al fascicolo.
Ignora i messaggi non del tenant o gia' presenti.
"""
svc = FascicoloService(db)
added = await svc.add_messages(
current_user.tenant_id,
fascicolo_id,
data.message_ids,
added_by=current_user.id,
)
return {"added": added}
@router.delete("/fascicoli/{fascicolo_id}/messages", response_model=dict)
async def remove_messages_from_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloRemoveMessagesRequest,
current_user: CurrentUser,
db: DB,
) -> dict:
"""Rimuove messaggi dal fascicolo (non li elimina dalla posta)."""
svc = FascicoloService(db)
removed = await svc.remove_messages(
current_user.tenant_id,
fascicolo_id,
data.message_ids,
)
return {"removed": removed}
# ─── Fascicoli di un messaggio ────────────────────────────────────────────────
@router.get("/messages/{message_id}/fascicoli", response_model=list[MessageFascicoloSummary])
async def get_message_fascicoli(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageFascicoloSummary]:
"""Restituisce i fascicoli a cui appartiene un messaggio."""
# Verifica che il messaggio esista nel tenant
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise NotFoundError(f"Messaggio {message_id} non trovato")
svc = FascicoloService(db)
fascicoli = await svc.get_message_fascicoli(current_user.tenant_id, message_id)
return [MessageFascicoloSummary.model_validate(f) for f in fascicoli]
+22 -4
View File
@@ -1,9 +1,10 @@
"""
Router tag (Label).
Router tag (Label) con supporto tassonomia gerarchica (Feature N2).
Endpoint:
- GET /labels elenca i tag del tenant
- POST /labels crea un nuovo tag (admin)
- GET /labels elenca i tag del tenant (flat)
- GET /labels/tree albero tassonomico (Ambito > Processo > Classificazione)
- POST /labels crea un nuovo tag / nodo tassonomico (admin)
- PATCH /labels/{id} modifica un tag (admin)
- DELETE /labels/{id} elimina un tag (admin)
@@ -31,6 +32,7 @@ from app.models.message import Message
from app.schemas.label import (
LabelCreate,
LabelResponse,
LabelTreeResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
@@ -77,12 +79,28 @@ async def list_labels(
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Elenca tutti i tag del tenant corrente."""
"""Elenca tutti i tag del tenant corrente (lista flat, include nodi tassonomici)."""
svc = LabelService(db)
labels = await svc.list_labels(current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.get("/labels/tree", response_model=list[LabelTreeResponse])
async def get_label_tree(
current_user: CurrentUser,
db: DB,
) -> list[LabelTreeResponse]:
"""
Restituisce la tassonomia come albero annidato.
Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ]
I nodi radice (parent_id=NULL) possono essere sia Ambiti tassonomici che label piatte.
"""
svc = LabelService(db)
return await svc.get_label_tree(current_user.tenant_id)
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
async def create_label(
data: LabelCreate,
+8
View File
@@ -595,6 +595,14 @@ async def update_message(
elif not data.is_conserved:
message.conserved_at = None
# Rischio e Riservatezza (Feature N3) — stringa vuota resetta a NULL
_VALID_RISK = {"low", "medium", "high", "critical"}
_VALID_CONF = {"public", "internal", "confidential", "secret"}
if data.risk_level is not None:
message.risk_level = data.risk_level if data.risk_level in _VALID_RISK else None
if data.confidentiality is not None:
message.confidentiality = data.confidentiality if data.confidentiality in _VALID_CONF else None
# Registra un evento di audit per ogni flag modificato
for field, (action_true, action_false) in _FLAG_ACTIONS.items():
value = getattr(data, field, None)
+114
View File
@@ -0,0 +1,114 @@
"""
Router preset permessi (sottoruoli nominati).
Endpoint:
GET /api/v1/permission-presets → lista preset del tenant
POST /api/v1/permission-presets → crea preset
GET /api/v1/permission-presets/{id} → dettaglio preset
PUT /api/v1/permission-presets/{id} → aggiorna preset
DELETE /api/v1/permission-presets/{id} → elimina preset
Accesso: admin e supervisor.
"""
import uuid
from fastapi import APIRouter
from app.dependencies import DB, SupervisorOrAdminUser
from app.schemas.permission_preset import (
PermissionPresetCreate,
PermissionPresetResponse,
PermissionPresetUpdate,
)
from app.services.permission_preset_service import PermissionPresetService
router = APIRouter(prefix="/permission-presets", tags=["Preset permessi"])
@router.get(
"",
response_model=list[PermissionPresetResponse],
summary="Lista preset permessi del tenant",
)
async def list_presets(
current_user: SupervisorOrAdminUser,
db: DB,
) -> list[PermissionPresetResponse]:
service = PermissionPresetService(db)
presets = await service.list_presets(current_user.tenant_id)
return [PermissionPresetResponse.model_validate(p) for p in presets]
@router.post(
"",
response_model=PermissionPresetResponse,
status_code=201,
summary="Crea un nuovo preset di permessi",
)
async def create_preset(
body: PermissionPresetCreate,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.create_preset(
tenant_id=current_user.tenant_id,
data=body,
created_by=current_user,
)
return PermissionPresetResponse.model_validate(preset)
@router.get(
"/{preset_id}",
response_model=PermissionPresetResponse,
summary="Dettaglio preset",
)
async def get_preset(
preset_id: uuid.UUID,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.get_preset(preset_id, current_user.tenant_id)
return PermissionPresetResponse.model_validate(preset)
@router.put(
"/{preset_id}",
response_model=PermissionPresetResponse,
summary="Aggiorna un preset",
)
async def update_preset(
preset_id: uuid.UUID,
body: PermissionPresetUpdate,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.update_preset(
preset_id=preset_id,
tenant_id=current_user.tenant_id,
data=body,
updated_by=current_user,
)
return PermissionPresetResponse.model_validate(preset)
@router.delete(
"/{preset_id}",
status_code=204,
summary="Elimina un preset",
)
async def delete_preset(
preset_id: uuid.UUID,
current_user: SupervisorOrAdminUser,
db: DB,
) -> None:
service = PermissionPresetService(db)
await service.delete_preset(
preset_id=preset_id,
tenant_id=current_user.tenant_id,
deleted_by=current_user,
)
+5 -5
View File
@@ -12,7 +12,7 @@ import uuid
from fastapi import APIRouter
from app.dependencies import AdminUser, CurrentUser, DB
from app.dependencies import AdminUser, CurrentUser, DB, SupervisorOrAdminUser
from app.schemas.permission import (
MailboxUserPermissionResponse,
PermissionGrantRequest,
@@ -35,7 +35,7 @@ async def grant_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
body: PermissionGrantRequest,
current_user: AdminUser,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionResponse:
service = PermissionService(db)
@@ -57,7 +57,7 @@ async def grant_permission(
async def revoke_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
current_user: AdminUser,
current_user: SupervisorOrAdminUser,
db: DB,
) -> None:
service = PermissionService(db)
@@ -75,7 +75,7 @@ async def revoke_permission(
)
async def list_mailbox_users(
mailbox_id: uuid.UUID,
current_user: AdminUser,
current_user: SupervisorOrAdminUser,
db: DB,
) -> list[MailboxUserPermissionResponse]:
service = PermissionService(db)
@@ -90,7 +90,7 @@ async def list_mailbox_users(
)
async def list_user_mailboxes(
user_id: uuid.UUID,
current_user: AdminUser,
current_user: SupervisorOrAdminUser,
db: DB,
) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db)
+3 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import audit_log, auth, contacts, deadlines, fascicoli, labels, mailboxes, messages, notifications, permission_presets, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -88,6 +88,7 @@ app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX)
app.include_router(permission_presets.router, prefix=API_PREFIX)
app.include_router(mailboxes.router, prefix=API_PREFIX)
app.include_router(messages.router, prefix=API_PREFIX)
app.include_router(send.router, prefix=API_PREFIX)
@@ -103,6 +104,7 @@ app.include_router(routing_rules.router, prefix=API_PREFIX)
app.include_router(contacts.router, prefix=API_PREFIX)
app.include_router(deadlines.router, prefix=API_PREFIX)
app.include_router(signatures.router, prefix=API_PREFIX)
app.include_router(fascicoli.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -14,3 +14,4 @@ from app.models.template import MessageTemplate # noqa: F401
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
from app.models.pec_contact import PecContact # noqa: F401
from app.models.signature import Signature, SignatureAssignment # noqa: F401
from app.models.fascicolo import Fascicolo, FascicoloMessage # noqa: F401
+96
View File
@@ -0,0 +1,96 @@
"""
Modelli Fascicolo e FascicoloMessage — fascicolazione pratiche.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
FascicoloStato = Enum(
"aperto", "chiuso", "archiviato",
name="fascicolo_stato",
create_type=False,
)
class Fascicolo(Base):
__tablename__ = "fascicoli"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
titolo: Mapped[str] = mapped_column(String(255), nullable=False)
numero_pratica: Mapped[str | None] = mapped_column(String(100), nullable=True)
stato: Mapped[str] = mapped_column(FascicoloStato, nullable=False, default="aperto")
categoria: Mapped[str | None] = mapped_column(String(100), nullable=True)
responsabile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
scadenza: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
fascicolo_messages: Mapped[list["FascicoloMessage"]] = relationship(
"FascicoloMessage", back_populates="fascicolo", cascade="all, delete-orphan"
)
messages: Mapped[list] = relationship(
"Message",
secondary="fascicolo_messages",
lazy="select",
viewonly=True,
)
__table_args__ = (
Index("idx_fascicoli_tenant", "tenant_id"),
Index("idx_fascicoli_stato", "stato"),
Index("idx_fascicoli_responsabile", "responsabile_id"),
)
def __repr__(self) -> str:
return f"<Fascicolo {self.id} {self.titolo!r} {self.stato!r}>"
class FascicoloMessage(Base):
__tablename__ = "fascicolo_messages"
fascicolo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("fascicoli.id", ondelete="CASCADE"),
primary_key=True,
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
added_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Relazioni
fascicolo: Mapped["Fascicolo"] = relationship("Fascicolo", back_populates="fascicolo_messages")
__table_args__ = (
Index("idx_fascicolo_messages_fascicolo", "fascicolo_id"),
Index("idx_fascicolo_messages_message", "message_id"),
)
+21 -4
View File
@@ -1,10 +1,17 @@
"""
Modelli Label e MessageLabel tagging messaggi.
Modelli Label e MessageLabel tagging messaggi con supporto tassonomia gerarchica.
Struttura ad albero (Feature N2 Tassonomia di Classificazione Multi-livello):
parent_id = NULL → Livello 0: Ambito (Area Aziendale)
parent_id = ID ambito → Livello 1: Processo
parent_id = ID processo → Livello 2: Classificazione (foglia)
Le label senza parent_id sono label "piatte" classiche (comportamento pre-esistente).
"""
import uuid
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy import CHAR, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -20,15 +27,25 @@ class Label(Base):
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
# Tassonomia: se parent_id è None è un nodo radice (Ambito) o label piatta
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("labels.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Nota: i vincoli di unicità sono gestiti da indici parziali nel DB:
# uq_label_name_root UNIQUE (tenant_id, name) WHERE parent_id IS NULL
# uq_label_name_parent UNIQUE (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
Index("idx_labels_parent", "parent_id"),
)
def __repr__(self) -> str:
return f"<Label {self.name!r}>"
return f"<Label {self.name!r} parent={self.parent_id}>"
class MessageLabel(Base):
+10
View File
@@ -24,6 +24,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
RiskLevel = Enum("low", "medium", "high", "critical", name="risk_level", create_type=False)
ConfidentialityLevel = Enum(
"public", "internal", "confidential", "secret",
name="confidentiality_level",
create_type=False,
)
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
PecState = Enum(
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
@@ -105,6 +111,10 @@ class Message(Base):
is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Rischio e Riservatezza (Feature N3)
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
+72
View File
@@ -0,0 +1,72 @@
"""
Modello PermissionPreset template riutilizzabili di permessi per casella.
Permette ad admin e supervisor di creare sottoruoli nominati (es. "Operatore Archivio")
con combinazioni predefinite di can_read/can_send/can_manage/can_conserve.
I preset sono per-tenant e vengono applicati opzionalmente al momento
dell'assegnazione di un operatore a una casella.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PermissionPreset(Base):
__tablename__ = "permission_presets"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
creator: Mapped["User"] = relationship( # noqa: F821
"User", foreign_keys=[created_by]
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_preset_name_tenant"),
Index("idx_preset_tenant", "tenant_id"),
Index("idx_preset_created_by", "created_by"),
)
def __repr__(self) -> str:
return (
f"<PermissionPreset name={self.name!r} tenant={self.tenant_id} "
f"read={self.can_read} send={self.can_send} "
f"manage={self.can_manage} conserve={self.can_conserve}>"
)
+92
View File
@@ -0,0 +1,92 @@
"""
Schemi Pydantic per Fascicolo (fascicolazione pratiche).
"""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ─── Schemi base ──────────────────────────────────────────────────────────────
class FascicoloCreate(BaseModel):
titolo: str = Field(..., min_length=1, max_length=255)
numero_pratica: Optional[str] = Field(None, max_length=100)
stato: Optional[str] = Field("aperto", pattern=r"^(aperto|chiuso|archiviato)$")
categoria: Optional[str] = Field(None, max_length=100)
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
class FascicoloUpdate(BaseModel):
titolo: Optional[str] = Field(None, min_length=1, max_length=255)
numero_pratica: Optional[str] = Field(None, max_length=100)
stato: Optional[str] = Field(None, pattern=r"^(aperto|chiuso|archiviato)$")
categoria: Optional[str] = Field(None, max_length=100)
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
class FascicoloResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
titolo: str
numero_pratica: Optional[str] = None
stato: str
categoria: Optional[str] = None
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
created_by: Optional[uuid.UUID] = None
created_at: datetime
updated_at: datetime
message_count: int = 0
model_config = {"from_attributes": True}
# ─── Messaggi nel fascicolo ───────────────────────────────────────────────────
class FascicoloMessageItem(BaseModel):
"""Riepilogo di un messaggio nel fascicolo."""
id: uuid.UUID
subject: Optional[str] = None
from_address: Optional[str] = None
to_addresses: Optional[list[str]] = None
direction: str
pec_type: str
state: str
mailbox_id: uuid.UUID
received_at: Optional[datetime] = None
sent_at: Optional[datetime] = None
created_at: datetime
added_at: datetime
model_config = {"from_attributes": True}
# ─── Operazioni sui messaggi del fascicolo ────────────────────────────────────
class FascicoloAddMessagesRequest(BaseModel):
message_ids: list[uuid.UUID] = Field(..., min_length=1)
class FascicoloRemoveMessagesRequest(BaseModel):
message_ids: list[uuid.UUID] = Field(..., min_length=1)
# ─── Lista fascicoli di un messaggio ─────────────────────────────────────────
class MessageFascicoloSummary(BaseModel):
"""Fascicolo sintetico per la vista nel dettaglio messaggio."""
id: uuid.UUID
titolo: str
numero_pratica: Optional[str] = None
stato: str
categoria: Optional[str] = None
model_config = {"from_attributes": True}
+32
View File
@@ -1,5 +1,6 @@
"""
Schemi Pydantic per Label (tag) e operazioni correlate.
Esteso con supporto tassonomia gerarchica (Feature N2).
"""
import uuid
@@ -8,14 +9,21 @@ from typing import Optional
from pydantic import BaseModel, Field
# ─── CRUD Label ───────────────────────────────────────────────────────────────
class LabelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
# Tassonomia: se valorizzato, questo nodo diventa figlio del parent indicato
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class LabelUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class LabelResponse(BaseModel):
@@ -23,10 +31,34 @@ class LabelResponse(BaseModel):
tenant_id: uuid.UUID
name: str
color: Optional[str] = None
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
model_config = {"from_attributes": True}
class LabelTreeResponse(BaseModel):
"""
Label con figli annidati per la vista ad albero della tassonomia.
Struttura restituita da GET /labels/tree:
[ Ambito1 { children: [ Processo1 { children: [ Classificazione1, ... ] } ] } ]
"""
id: uuid.UUID
tenant_id: uuid.UUID
name: str
color: Optional[str] = None
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
children: list["LabelTreeResponse"] = []
model_config = {"from_attributes": True}
# Necessario per il tipo ricorsivo
LabelTreeResponse.model_rebuild()
# ─── Richieste per assegnazione tag a messaggi ────────────────────────────────
class MessageLabelSetRequest(BaseModel):
+8
View File
@@ -76,6 +76,9 @@ class MessageResponse(BaseModel):
pending_conservation_at: Optional[datetime] = None
is_conserved: bool = False
conserved_at: Optional[datetime] = None
# Rischio e Riservatezza (Feature N3)
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
raw_eml_path: Optional[str] = None
created_at: datetime
updated_at: datetime
@@ -116,6 +119,9 @@ class MessageUpdateRequest(BaseModel):
is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None
# Rischio e Riservatezza (Feature N3) — None = non modificare; stringa vuota = reset a NULL
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
class MessageBulkUpdateRequest(BaseModel):
@@ -126,6 +132,8 @@ class MessageBulkUpdateRequest(BaseModel):
is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
class MessageBulkUpdateResponse(BaseModel):
+42
View File
@@ -0,0 +1,42 @@
"""
Schema Pydantic per i preset di permessi (sottoruoli nominati).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class PermissionPresetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Nome del preset")
description: str | None = Field(None, description="Descrizione opzionale")
can_read: bool = Field(True, description="Permesso lettura messaggi")
can_send: bool = Field(False, description="Permesso invio PEC")
can_manage: bool = Field(False, description="Permesso gestione casella")
can_conserve: bool = Field(False, description="Permesso conservazione documenti")
class PermissionPresetUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
can_read: bool | None = None
can_send: bool | None = None
can_manage: bool | None = None
can_conserve: bool | None = None
class PermissionPresetResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None
can_read: bool
can_send: bool
can_manage: bool
can_conserve: bool
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+20 -2
View File
@@ -10,7 +10,16 @@ from pydantic import BaseModel, field_validator
# Valori validi per field nelle condizioni
CONDITION_FIELDS = Literal[
"from_address", "to_address", "subject", "mailbox_id", "pec_type"
"from_address",
"to_address",
"subject",
"mailbox_id",
"pec_type",
# Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta/nodo
"has_label",
# Rischio e Riservatezza (N3): verifica il livello gia' impostato
"risk_level",
"confidentiality",
]
# Operatori supportati
CONDITION_OPERATORS = Literal[
@@ -18,7 +27,16 @@ CONDITION_OPERATORS = Literal[
]
# Tipi di azione
ACTION_TYPES = Literal[
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
"apply_label",
"assign_vbox",
"mark_read",
"mark_starred",
"notify_webhook",
# Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione)
"apply_taxonomy",
# Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza
"set_risk_level",
"set_confidentiality",
]
+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