From 3fd3c72f064058cc7fa2e144bbbecdc88684a42a Mon Sep 17 00:00:00 2001 From: Matteo Giustini Date: Wed, 17 Jun 2026 21:47:46 +0200 Subject: [PATCH] Fascicoli+Tassonomia+permessi --- .../alembic/versions/0016_add_fascicoli.py | 115 ++++ .../versions/0017_add_taxonomy_to_labels.py | 78 +++ .../versions/0018_add_risk_confidentiality.py | 80 +++ .../versions/0019_add_permission_presets.py | 77 +++ backend/app/api/v1/fascicoli.py | 225 +++++++ backend/app/api/v1/labels.py | 26 +- backend/app/api/v1/messages.py | 8 + backend/app/api/v1/permission_presets.py | 114 ++++ backend/app/api/v1/permissions.py | 10 +- backend/app/main.py | 4 +- backend/app/models/__init__.py | 1 + backend/app/models/fascicolo.py | 96 +++ backend/app/models/label.py | 25 +- backend/app/models/message.py | 10 + backend/app/models/permission_preset.py | 72 +++ backend/app/schemas/fascicolo.py | 92 +++ backend/app/schemas/label.py | 32 + backend/app/schemas/message.py | 8 + backend/app/schemas/permission_preset.py | 42 ++ backend/app/schemas/routing_rule.py | 22 +- backend/app/services/fascicolo_service.py | 270 +++++++++ backend/app/services/label_service.py | 147 ++++- .../app/services/permission_preset_service.py | 125 ++++ backend/app/services/permission_service.py | 10 +- backend/app/services/routing_rule_service.py | 57 +- frontend/src/App.tsx | 9 + frontend/src/api/fascicoli.api.ts | 120 ++++ frontend/src/api/labels.api.ts | 5 + frontend/src/api/messages.api.ts | 18 + frontend/src/api/permission_presets.api.ts | 38 ++ frontend/src/api/routing_rules.api.ts | 26 +- frontend/src/components/Layout/Sidebar.tsx | 60 +- .../src/components/RiskBadge/RiskBadge.tsx | 145 +++++ .../src/pages/Fascicoli/FascicoliPage.tsx | 442 ++++++++++++++ .../pages/Fascicoli/FascicoloDetailPage.tsx | 547 ++++++++++++++++++ frontend/src/pages/Inbox/InboxPage.tsx | 9 + .../pages/MessageDetail/MessageDetailPage.tsx | 438 ++++++++++++++ .../PermissionPresetsPage.tsx | 344 +++++++++++ .../src/pages/Permissions/PermissionsPage.tsx | 95 ++- .../pages/RoutingRules/RoutingRulesPage.tsx | 94 ++- frontend/src/pages/Taxonomy/TaxonomyPage.tsx | 454 +++++++++++++++ frontend/src/types/api.types.ts | 63 +- 42 files changed, 4554 insertions(+), 99 deletions(-) create mode 100644 backend/alembic/versions/0016_add_fascicoli.py create mode 100644 backend/alembic/versions/0017_add_taxonomy_to_labels.py create mode 100644 backend/alembic/versions/0018_add_risk_confidentiality.py create mode 100644 backend/alembic/versions/0019_add_permission_presets.py create mode 100644 backend/app/api/v1/fascicoli.py create mode 100644 backend/app/api/v1/permission_presets.py create mode 100644 backend/app/models/fascicolo.py create mode 100644 backend/app/models/permission_preset.py create mode 100644 backend/app/schemas/fascicolo.py create mode 100644 backend/app/schemas/permission_preset.py create mode 100644 backend/app/services/fascicolo_service.py create mode 100644 backend/app/services/permission_preset_service.py create mode 100644 frontend/src/api/fascicoli.api.ts create mode 100644 frontend/src/api/permission_presets.api.ts create mode 100644 frontend/src/components/RiskBadge/RiskBadge.tsx create mode 100644 frontend/src/pages/Fascicoli/FascicoliPage.tsx create mode 100644 frontend/src/pages/Fascicoli/FascicoloDetailPage.tsx create mode 100644 frontend/src/pages/PermissionPresets/PermissionPresetsPage.tsx create mode 100644 frontend/src/pages/Taxonomy/TaxonomyPage.tsx diff --git a/backend/alembic/versions/0016_add_fascicoli.py b/backend/alembic/versions/0016_add_fascicoli.py new file mode 100644 index 0000000..ac84f53 --- /dev/null +++ b/backend/alembic/versions/0016_add_fascicoli.py @@ -0,0 +1,115 @@ +""" +Migrazione 0016: tabelle fascicoli e fascicolo_messages. +Fascicolazione / Dossier — raggruppa piu' comunicazioni PEC in una pratica. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0016" +down_revision = "0015" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ─── Tabella fascicoli ───────────────────────────────────────────────────── + # L'ENUM fascicolo_stato viene creato automaticamente da SQLAlchemy + # come parte di op.create_table (no op.execute separato) + op.create_table( + "fascicoli", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("titolo", sa.String(255), nullable=False), + sa.Column("numero_pratica", sa.String(100), nullable=True), + sa.Column( + "stato", + sa.Enum("aperto", "chiuso", "archiviato", name="fascicolo_stato"), + nullable=False, + server_default="aperto", + ), + sa.Column("categoria", sa.String(100), nullable=True), + sa.Column( + "responsabile_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("scadenza", sa.DateTime(timezone=True), nullable=True), + sa.Column("note", sa.Text, nullable=True), + sa.Column( + "created_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("idx_fascicoli_tenant", "fascicoli", ["tenant_id"]) + op.create_index("idx_fascicoli_stato", "fascicoli", ["stato"]) + op.create_index("idx_fascicoli_responsabile", "fascicoli", ["responsabile_id"]) + + # ─── Tabella fascicolo_messages (N:M) ────────────────────────────────────── + op.create_table( + "fascicolo_messages", + sa.Column( + "fascicolo_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("fascicoli.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "message_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("messages.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "added_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "added_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + "idx_fascicolo_messages_fascicolo", "fascicolo_messages", ["fascicolo_id"] + ) + op.create_index( + "idx_fascicolo_messages_message", "fascicolo_messages", ["message_id"] + ) + + +def downgrade() -> None: + op.drop_index("idx_fascicolo_messages_message", table_name="fascicolo_messages") + op.drop_index("idx_fascicolo_messages_fascicolo", table_name="fascicolo_messages") + op.drop_table("fascicolo_messages") + + op.drop_index("idx_fascicoli_responsabile", table_name="fascicoli") + op.drop_index("idx_fascicoli_stato", table_name="fascicoli") + op.drop_index("idx_fascicoli_tenant", table_name="fascicoli") + op.drop_table("fascicoli") + + op.execute("DROP TYPE fascicolo_stato") diff --git a/backend/alembic/versions/0017_add_taxonomy_to_labels.py b/backend/alembic/versions/0017_add_taxonomy_to_labels.py new file mode 100644 index 0000000..9969c7c --- /dev/null +++ b/backend/alembic/versions/0017_add_taxonomy_to_labels.py @@ -0,0 +1,78 @@ +""" +Migrazione 0017: Tassonomia di Classificazione Multi-livello (Feature N2). + +Estende la tabella `labels` con: + - parent_id: FK self-referenziale (nullable) per struttura ad albero + - description: testo descrittivo opzionale + +Struttura tassonomica: + Livello 0 (radice) = Ambito (Area Aziendale) — parent_id IS NULL + Livello 1 = Processo — parent_id = ID Ambito + Livello 2 (foglia) = Classificazione — parent_id = ID Processo + +I vincoli di unicità vengono sostituiti con indici parziali per supportare +nomi identici a diversi livelli dell'albero. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Aggiunge parent_id (FK self-referenziale nullable) ───────────────────── + op.add_column( + "labels", + sa.Column( + "parent_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + ) + op.create_foreign_key( + "fk_labels_parent", + "labels", + "labels", + ["parent_id"], + ["id"], + ondelete="CASCADE", + ) + + # ── Aggiunge description ──────────────────────────────────────────────────── + op.add_column( + "labels", + sa.Column("description", sa.Text(), nullable=True), + ) + + # ── Indice su parent_id per query gerarchiche ─────────────────────────────── + op.create_index("idx_labels_parent", "labels", ["parent_id"]) + + # ── Sostituisce il vincolo di unicità con indici parziali ─────────────────── + # Il vecchio vincolo (tenant_id, name) non supporta nomi uguali a livelli diversi + op.drop_constraint("uq_label_name_tenant", "labels") + + # Nodi radice: nome unico per tenant (parent_id IS NULL) + op.execute( + "CREATE UNIQUE INDEX uq_label_name_root " + "ON labels (tenant_id, name) WHERE parent_id IS NULL" + ) + # Nodi non-radice: nome unico per (tenant, parent) — stesso nome ammesso sotto parent diversi + op.execute( + "CREATE UNIQUE INDEX uq_label_name_parent " + "ON labels (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS uq_label_name_parent") + op.execute("DROP INDEX IF EXISTS uq_label_name_root") + op.create_unique_constraint("uq_label_name_tenant", "labels", ["tenant_id", "name"]) + op.drop_index("idx_labels_parent", table_name="labels") + op.drop_constraint("fk_labels_parent", "labels", type_="foreignkey") + op.drop_column("labels", "description") + op.drop_column("labels", "parent_id") diff --git a/backend/alembic/versions/0018_add_risk_confidentiality.py b/backend/alembic/versions/0018_add_risk_confidentiality.py new file mode 100644 index 0000000..61b11b7 --- /dev/null +++ b/backend/alembic/versions/0018_add_risk_confidentiality.py @@ -0,0 +1,80 @@ +""" +Migrazione 0018: Livello di Rischio e Riservatezza per Comunicazione (Feature N3). + +Aggiunge alla tabella `messages`: + - risk_level ENUM(low, medium, high, critical) nullable + - confidentiality ENUM(public, internal, confidential, secret) nullable + +Entrambi i campi sono nullable: rimangono NULL finche' non vengono +impostati manualmente o da una regola di smistamento (set_risk_level / +set_confidentiality). +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0018" +down_revision = "0017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Crea i nuovi ENUM types ───────────────────────────────────────────────── + risk_level_enum = postgresql.ENUM( + "low", "medium", "high", "critical", + name="risk_level", + create_type=True, + ) + confidentiality_enum = postgresql.ENUM( + "public", "internal", "confidential", "secret", + name="confidentiality_level", + create_type=True, + ) + risk_level_enum.create(op.get_bind(), checkfirst=True) + confidentiality_enum.create(op.get_bind(), checkfirst=True) + + # ── Aggiunge le colonne a messages ────────────────────────────────────────── + op.add_column( + "messages", + sa.Column( + "risk_level", + sa.Enum("low", "medium", "high", "critical", name="risk_level", create_type=False), + nullable=True, + ), + ) + op.add_column( + "messages", + sa.Column( + "confidentiality", + sa.Enum("public", "internal", "confidential", "secret", name="confidentiality_level", create_type=False), + nullable=True, + ), + ) + + # ── Indici per filtrare rapidamente per rischio/riservatezza ──────────────── + op.create_index( + "idx_messages_risk_level", + "messages", + ["tenant_id", "risk_level"], + postgresql_where=sa.text("risk_level IS NOT NULL"), + ) + op.create_index( + "idx_messages_confidentiality", + "messages", + ["tenant_id", "confidentiality"], + postgresql_where=sa.text("confidentiality IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("idx_messages_confidentiality", table_name="messages") + op.drop_index("idx_messages_risk_level", table_name="messages") + op.drop_column("messages", "confidentiality") + op.drop_column("messages", "risk_level") + + risk_level_enum = postgresql.ENUM(name="risk_level") + confidentiality_enum = postgresql.ENUM(name="confidentiality_level") + risk_level_enum.drop(op.get_bind(), checkfirst=True) + confidentiality_enum.drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/0019_add_permission_presets.py b/backend/alembic/versions/0019_add_permission_presets.py new file mode 100644 index 0000000..a4599bf --- /dev/null +++ b/backend/alembic/versions/0019_add_permission_presets.py @@ -0,0 +1,77 @@ +""" +Migrazione 0019: Tabella permission_presets (sottoruoli/template permessi). + +Aggiunge la tabella `permission_presets` che consente ad admin e supervisor +di definire template nominati di permessi riutilizzabili (es. "Operatore Archivio", +"Operatore Invio", ecc.) da applicare agli operatori sulle caselle PEC. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0019" +down_revision = "0018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "permission_presets", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column( + "tenant_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("can_read", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("can_send", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("can_manage", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("can_conserve", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "created_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + + # Unicita' nome per tenant + op.create_unique_constraint( + "uq_preset_name_tenant", + "permission_presets", + ["tenant_id", "name"], + ) + + # Indici + op.create_index("idx_preset_tenant", "permission_presets", ["tenant_id"]) + op.create_index("idx_preset_created_by", "permission_presets", ["created_by"]) + + +def downgrade() -> None: + op.drop_index("idx_preset_created_by", table_name="permission_presets") + op.drop_index("idx_preset_tenant", table_name="permission_presets") + op.drop_constraint("uq_preset_name_tenant", "permission_presets", type_="unique") + op.drop_table("permission_presets") diff --git a/backend/app/api/v1/fascicoli.py b/backend/app/api/v1/fascicoli.py new file mode 100644 index 0000000..660ff94 --- /dev/null +++ b/backend/app/api/v1/fascicoli.py @@ -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] diff --git a/backend/app/api/v1/labels.py b/backend/app/api/v1/labels.py index 2366a7a..84da6e7 100644 --- a/backend/app/api/v1/labels.py +++ b/backend/app/api/v1/labels.py @@ -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, diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 90fd74a..5386612 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -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) diff --git a/backend/app/api/v1/permission_presets.py b/backend/app/api/v1/permission_presets.py new file mode 100644 index 0000000..c061818 --- /dev/null +++ b/backend/app/api/v1/permission_presets.py @@ -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, + ) diff --git a/backend/app/api/v1/permissions.py b/backend/app/api/v1/permissions.py index 5d753d7..1e3e344 100644 --- a/backend/app/api/v1/permissions.py +++ b/backend/app/api/v1/permissions.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 5dca76f..6c81469 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6cf02e6..761f097 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/fascicolo.py b/backend/app/models/fascicolo.py new file mode 100644 index 0000000..9bc4f8d --- /dev/null +++ b/backend/app/models/fascicolo.py @@ -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"" + + +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"), + ) diff --git a/backend/app/models/label.py b/backend/app/models/label.py index 4900245..6780d8e 100644 --- a/backend/app/models/label.py +++ b/backend/app/models/label.py @@ -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"