Fascicoli+Tassonomia+permessi
This commit is contained in:
@@ -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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>"
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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