vboxes fix

This commit is contained in:
2026-03-19 14:28:09 +01:00
parent b7f7c1f7c0
commit 06dfbfcbc4
30 changed files with 4405 additions and 166 deletions
+237
View File
@@ -0,0 +1,237 @@
"""
Router tag (Label).
Endpoint:
- GET /labels elenca i tag del tenant
- POST /labels crea un nuovo tag (admin)
- PATCH /labels/{id} modifica un tag (admin)
- DELETE /labels/{id} elimina un tag (admin)
- GET /messages/{id}/labels tag di un messaggio
- PUT /messages/{id}/labels imposta i tag di un messaggio (sostituisce)
- POST /messages/{id}/labels/add aggiunge tag a un messaggio
- POST /messages/{id}/labels/remove rimuove tag da un messaggio
- POST /messages/bulk-labels aggiunge/rimuove tag in blocco
Permessi:
- GET /labels: tutti gli utenti autenticati
- POST/PATCH/DELETE: solo admin
- Operazioni su messaggi: utenti con accesso alla casella del messaggio
"""
import uuid
from fastapi import APIRouter, 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.label import (
LabelCreate,
LabelResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
MessageLabelAddRequest,
MessageLabelRemoveRequest,
MessageLabelSetRequest,
)
from app.services.label_service import LabelService
router = APIRouter(tags=["Labels"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
async def _check_message_access(
message_id: uuid.UUID,
current_user,
db,
) -> Message:
"""Verifica che il messaggio esista e l'utente vi abbia accesso."""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
message = result.scalar_one_or_none()
if not message:
raise NotFoundError(f"Messaggio {message_id} non trovato")
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
raise ForbiddenError("Accesso al messaggio non autorizzato")
return message
# ─── CRUD Label ───────────────────────────────────────────────────────────────
@router.get("/labels", response_model=list[LabelResponse])
async def list_labels(
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Elenca tutti i tag del tenant corrente."""
svc = LabelService(db)
labels = await svc.list_labels(current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
async def create_label(
data: LabelCreate,
current_user: AdminUser,
db: DB,
) -> LabelResponse:
"""Crea un nuovo tag (solo admin)."""
svc = LabelService(db)
label = await svc.create_label(current_user.tenant_id, data)
return LabelResponse.model_validate(label)
@router.patch("/labels/{label_id}", response_model=LabelResponse)
async def update_label(
label_id: uuid.UUID,
data: LabelUpdate,
current_user: AdminUser,
db: DB,
) -> LabelResponse:
"""Modifica un tag esistente (solo admin)."""
svc = LabelService(db)
label = await svc.update_label(current_user.tenant_id, label_id, data)
return LabelResponse.model_validate(label)
@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_label(
label_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un tag (solo admin). Viene rimosso automaticamente da tutti i messaggi."""
svc = LabelService(db)
await svc.delete_label(current_user.tenant_id, label_id)
# ─── Tag su singolo messaggio ─────────────────────────────────────────────────
@router.get("/messages/{message_id}/labels", response_model=list[LabelResponse])
async def get_message_labels(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Elenca i tag assegnati a un messaggio."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.get_message_labels(message_id, current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels]
@router.put("/messages/{message_id}/labels", response_model=list[LabelResponse])
async def set_message_labels(
message_id: uuid.UUID,
data: MessageLabelSetRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""
Sostituisce tutti i tag di un messaggio.
Passare una lista vuota per rimuovere tutti i tag.
"""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.set_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/messages/{message_id}/labels/add", response_model=list[LabelResponse])
async def add_message_labels(
message_id: uuid.UUID,
data: MessageLabelAddRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.add_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
@router.post("/messages/{message_id}/labels/remove", response_model=list[LabelResponse])
async def remove_message_labels(
message_id: uuid.UUID,
data: MessageLabelRemoveRequest,
current_user: CurrentUser,
db: DB,
) -> list[LabelResponse]:
"""Rimuove specifici tag da un messaggio."""
await _check_message_access(message_id, current_user, db)
svc = LabelService(db)
labels = await svc.remove_message_labels(
message_id, current_user.tenant_id, data.label_ids
)
return [LabelResponse.model_validate(l) for l in labels]
# ─── Bulk labels ──────────────────────────────────────────────────────────────
@router.post("/messages/bulk-labels", response_model=MessageBulkLabelResponse)
async def bulk_labels(
data: MessageBulkLabelRequest,
current_user: CurrentUser,
db: DB,
) -> MessageBulkLabelResponse:
"""
Aggiunge o rimuove tag da più messaggi in blocco.
- action="add": aggiunge i tag ai messaggi indicati
- action="remove": rimuove i tag dai messaggi indicati
I messaggi non accessibili all'utente vengono silenziosamente ignorati.
"""
if not data.message_ids or not data.label_ids:
return MessageBulkLabelResponse(updated=0)
# Per utenti non-admin filtra per caselle accessibili
message_ids = [str(mid) for mid in data.message_ids]
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible = await perm_svc.get_visible_mailboxes(current_user)
visible_set = set(visible) if visible else set()
# Filtra i messaggi per caselle visibili
result = await db.execute(
select(Message.id).where(
Message.id.in_(data.message_ids),
Message.tenant_id == current_user.tenant_id,
Message.mailbox_id.in_(visible_set),
)
)
filtered_ids = list(result.scalars().all())
else:
filtered_ids = data.message_ids
svc = LabelService(db)
if data.action == "add":
updated = await svc.bulk_add_labels(
filtered_ids, current_user.tenant_id, data.label_ids
)
else:
updated = await svc.bulk_remove_labels(
filtered_ids, current_user.tenant_id, data.label_ids
)
return MessageBulkLabelResponse(updated=updated)
+47 -6
View File
@@ -23,11 +23,13 @@ from fastapi import APIRouter, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import ForbiddenError, NotFoundError
from app.database import get_db
from app.dependencies import CurrentUser, DB
from app.models.label import Label
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
@@ -107,12 +109,20 @@ async def _resolve_message(
current_user,
db: AsyncSession,
) -> Message:
"""Carica il messaggio e verifica i permessi di accesso."""
"""Carica il messaggio e verifica i permessi di accesso.
L'accesso è consentito se:
1. L'utente è admin del tenant, oppure
2. L'utente ha un permesso diretto can_read sulla casella, oppure
3. L'utente è assegnato a una Virtual Box attiva che include la casella.
"""
result = await db.execute(
select(Message).where(
select(Message)
.where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
.options(selectinload(Message.labels))
)
message = result.scalar_one_or_none()
if not message:
@@ -121,8 +131,39 @@ async def _resolve_message(
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
raise ForbiddenError("Accesso al messaggio non autorizzato")
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
if not has_direct_access:
# Verifica accesso tramite Virtual Box:
# l'utente deve essere assegnato a una VBox attiva
# che include la casella del messaggio.
from app.models.virtual_box import (
VirtualBox,
VirtualBoxAssignment,
virtual_box_mailboxes,
)
vbox_result = await db.execute(
select(VirtualBox.id)
.join(
VirtualBoxAssignment,
VirtualBox.id == VirtualBoxAssignment.virtual_box_id,
)
.join(
virtual_box_mailboxes,
VirtualBox.id == virtual_box_mailboxes.c.virtual_box_id,
)
.where(
VirtualBoxAssignment.user_id == current_user.id,
virtual_box_mailboxes.c.mailbox_id == message.mailbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.limit(1)
)
has_vbox_access = vbox_result.scalar_one_or_none() is not None
if not has_vbox_access:
raise ForbiddenError("Accesso al messaggio non autorizzato")
return message
@@ -160,7 +201,6 @@ async def list_messages(
# ── Filtro Virtual Box ────────────────────────────────────────────────────
vbox_rules: list = []
if vbox_id is not None:
from sqlalchemy.orm import selectinload
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment
vbox_result = await db.execute(
@@ -258,7 +298,8 @@ async def list_messages(
# Ordinamento e paginazione
q = (
q.order_by(
q.options(selectinload(Message.labels))
.order_by(
Message.received_at.desc().nullslast(),
Message.created_at.desc(),
)
+94 -30
View File
@@ -1,20 +1,22 @@
"""
Router API Invio PEC (Fase 4).
Router API Invio PEC.
Endpoint:
POST /send invia una nuova PEC (crea Message + SendJob, accoda job)
GET /send/jobs lista job di invio del tenant (paginata)
GET /send/jobs/{id} dettaglio di un singolo job
DELETE /send/jobs/{id} annulla job se ancora pending/retrying
POST /send invia una PEC via JSON (body_text, senza allegati)
POST /send/multipart invia una PEC con allegati (multipart/form-data)
GET /send/jobs lista job di invio del tenant (paginata)
GET /send/jobs/{id} dettaglio di un singolo job
DELETE /send/jobs/{id} annulla job se ancora pending/retrying
"""
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, Query, status
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from app.core.exceptions import ForbiddenError
from app.dependencies import AdminUser, CurrentUser, DB
from app.dependencies import CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
from app.services.send_service import SendService
@@ -32,18 +34,16 @@ def _job_response(job) -> SendJobResponse:
return SendJobResponse.model_validate(job)
# ─── Endpoints ────────────────────────────────────────────────────────────────
# ─── POST /send (JSON retrocompatibile) ────────────────────────────────────
@router.post(
"",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC",
summary="Invia una PEC (JSON, senza allegati)",
description=(
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
"Il job viene eseguito in background con retry automatico. "
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
"Per inviare allegati utilizzare `POST /send/multipart`."
),
)
async def create_send_job(
@@ -54,11 +54,87 @@ async def create_send_job(
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await db.commit()
# Refresh per ottenere tutti i valori default dal DB
await db.refresh(job)
return _job_response(job)
# ─── POST /send/multipart (Form + files) ─────────────────────────────────────
@router.post(
"/multipart",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC con allegati (multipart/form-data)",
description=(
"Accetta un form multipart con:\n"
"- **data**: JSON string con i campi della PEC (stessa struttura di `POST /send`, "
" più il campo opzionale `body_html`)\n"
"- **attachments**: zero o più file allegati (max 20 MB ciascuno)\n\n"
"Carica gli allegati su MinIO e crea i record `Attachment` nel DB prima di accodare il job."
),
)
async def create_send_job_multipart(
current_user: CurrentUser,
db: DB,
data: str = Form(
...,
description="JSON con i dati della PEC (campi SendPecRequest + body_html opzionale)",
),
attachments: list[UploadFile] = File(
default=[],
description="File allegati (0 o più, max 20 MB ciascuno)",
),
) -> SendJobResponse:
# ── Parse del JSON ────────────────────────────────────────────────────────
try:
raw = json.loads(data)
pec_data = SendPecRequest.model_validate(raw)
# Estrai body_html (non presente nel modello base)
body_html: str | None = raw.get("body_html") or None
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Dati PEC non validi: {exc}",
)
# ── Leggi file allegati ───────────────────────────────────────────────────
MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB
files_data: list[dict] = []
for upload in attachments:
content = await upload.read()
if not content:
continue # Salta file vuoti
if len(content) > MAX_FILE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Il file '{upload.filename}' supera il limite di 20 MB",
)
files_data.append(
{
"filename": upload.filename or "allegato",
"content": content,
"content_type": upload.content_type or "application/octet-stream",
}
)
# ── Sovrascrive body_html se fornito ──────────────────────────────────────
if body_html:
pec_data.body_html = body_html # type: ignore[attr-defined]
svc = _svc(db)
job = await svc.create_send_job(
current_user=current_user,
data=pec_data,
attachments=files_data if files_data else None,
)
await db.commit()
await db.refresh(job)
return _job_response(job)
# ─── GET /send/jobs ────────────────────────────────────────────────────────────
@router.get(
"/jobs",
response_model=SendJobListResponse,
@@ -76,15 +152,8 @@ async def list_send_jobs(
description="Filtra per stato: pending | sending | sent | failed | retrying",
),
) -> SendJobListResponse:
"""
Elenca i job di invio del tenant.
Gli admin vedono tutti i job; gli operatori vedono solo i job
delle caselle su cui hanno permesso can_read.
"""
svc = _svc(db)
# Filtro opzionale per casella: verifica accesso se non admin
if mailbox_id and not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, mailbox_id):
@@ -105,6 +174,8 @@ async def list_send_jobs(
)
# ─── GET /send/jobs/{id} ───────────────────────────────────────────────────────
@router.get(
"/jobs/{job_id}",
response_model=SendJobResponse,
@@ -115,11 +186,9 @@ async def get_send_job(
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
"""Recupera lo stato di un singolo job di invio."""
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
# Verifica accesso alla casella se non admin
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_read(current_user, job.mailbox_id):
@@ -128,6 +197,8 @@ async def get_send_job(
return _job_response(job)
# ─── DELETE /send/jobs/{id} ────────────────────────────────────────────────────
@router.delete(
"/jobs/{job_id}",
status_code=status.HTTP_204_NO_CONTENT,
@@ -138,16 +209,9 @@ async def cancel_send_job(
current_user: CurrentUser,
db: DB,
) -> None:
"""
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
Non è possibile annullare un invio già partito (stato sending) o
completato (sent).
"""
svc = _svc(db)
# Verifica che l'utente possa agire su questo job
job = await svc.get_send_job(job_id, current_user.tenant_id)
if not current_user.is_admin:
perm_svc = PermissionService(db)
if not await perm_svc.check_can_send(current_user, job.mailbox_id):