vbox funzionanti

This commit is contained in:
2026-03-19 11:41:10 +01:00
parent 538d6a6bec
commit b7f7c1f7c0
32 changed files with 6043 additions and 262 deletions
+149 -2
View File
@@ -31,6 +31,8 @@ from app.dependencies import CurrentUser, DB
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
MessageBulkUpdateRequest,
MessageBulkUpdateResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
@@ -42,6 +44,49 @@ settings = get_settings()
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _apply_vbox_rule(q, field: str, operator: str, value: str):
"""
Applica una singola regola di Virtual Box alla query SQLAlchemy.
field : subject | from_address | to_address | imap_folder
operator : contains | equals | starts_with | ends_with | regex
"""
if field == "subject":
col = Message.subject
elif field == "from_address":
col = Message.from_address
elif field == "to_address":
# to_addresses è ARRAY(Text) converte in stringa per il confronto
arr_text = func.array_to_string(Message.to_addresses, ",")
if operator == "contains":
return q.where(arr_text.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(arr_text.ilike(value))
elif operator == "starts_with":
return q.where(arr_text.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(arr_text.ilike(f"%{value}"))
elif operator == "regex":
return q.where(arr_text.op("~*")(value))
return q
elif field == "imap_folder":
col = Message.imap_folder
else:
return q # campo non supportato ignorato
if operator == "contains":
return q.where(col.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(func.lower(col) == value.lower())
elif operator == "starts_with":
return q.where(col.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(col.ilike(f"%{value}"))
elif operator == "regex":
return q.where(col.op("~*")(value))
return q
async def _get_visible_mailbox_ids(
user, db: AsyncSession
) -> Optional[list[uuid.UUID]]:
@@ -89,6 +134,7 @@ async def list_messages(
current_user: CurrentUser,
db: DB,
# Filtri
vbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per Virtual Box assegnata"),
mailbox_id: Optional[uuid.UUID] = Query(None),
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
state: Optional[str] = Query(None),
@@ -106,17 +152,61 @@ async def list_messages(
- `is_archived=False` (default) esclude i messaggi archiviati.
- `search` cerca su subject, from_address, to_addresses.
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
"""
# Determinare le caselle visibili
# Determinare le caselle visibili (normale check permessi)
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# ── 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(
select(VirtualBox)
.where(
VirtualBox.id == vbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.mailboxes),
)
)
vbox = vbox_result.scalar_one_or_none()
if not vbox:
raise NotFoundError("Virtual Box")
# Non-admin: verifica che l'utente sia assegnato alla VBox
if not current_user.is_admin:
assign_result = await db.execute(
select(VirtualBoxAssignment).where(
VirtualBoxAssignment.virtual_box_id == vbox_id,
VirtualBoxAssignment.user_id == current_user.id,
)
)
if not assign_result.scalar_one_or_none():
raise ForbiddenError("Virtual Box non accessibile")
# L'assegnazione alla VBox garantisce accesso alle sue caselle:
# sovrascrive il filtro permessi normali per questa query.
if vbox.mailboxes:
visible_mailbox_ids = [m.id for m in vbox.mailboxes]
# Se la VBox non ha caselle esplicitamente associate,
# si mantiene il filtro permessi normale (visible_mailbox_ids invariato).
vbox_rules = vbox.rules or []
# ─────────────────────────────────────────────────────────────────────────
# Query base
q = select(Message).where(
Message.tenant_id == current_user.tenant_id,
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
)
# Filtro caselle visibili per non-admin
# Filtro caselle visibili per non-admin (o dopo override VBox)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
# Nessuna casella accessibile → lista vuota
@@ -158,6 +248,10 @@ async def list_messages(
)
)
# Applica le regole della Virtual Box (AND tra le regole)
for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
# Conteggio totale
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
@@ -183,6 +277,59 @@ async def list_messages(
)
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages(
data: MessageBulkUpdateRequest,
current_user: CurrentUser,
db: DB,
) -> MessageBulkUpdateResponse:
"""
Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi.
Restituisce il numero di messaggi aggiornati e la lista aggiornata.
I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
"""
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
# Carica tutti i messaggi del tenant
result = await db.execute(
select(Message).where(
Message.id.in_(data.ids),
Message.tenant_id == current_user.tenant_id,
)
)
messages = list(result.scalars().all())
# Filtra per permessi se non admin
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()
messages = [m for m in messages if m.mailbox_id in visible_set]
now = datetime.now(timezone.utc)
for message in messages:
if data.is_starred is not None:
message.is_starred = data.is_starred
if data.is_archived is not None:
message.is_archived = data.is_archived
if data.is_archived and not message.archived_at:
message.archived_at = now
elif not data.is_archived:
message.archived_at = None
await db.commit()
for message in messages:
await db.refresh(message)
return MessageBulkUpdateResponse(
updated=len(messages),
items=[MessageResponse.model_validate(m) for m in messages],
)
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
message_id: uuid.UUID,
+249
View File
@@ -0,0 +1,249 @@
"""
Router Notifiche Multi-canale (Fase 2).
Endpoint:
POST /notifications/channels → crea canale
GET /notifications/channels → lista canali
GET /notifications/channels/{id} → dettaglio canale
PATCH /notifications/channels/{id} → aggiorna canale
DELETE /notifications/channels/{id} → elimina canale
POST /notifications/channels/{id}/test → test canale
POST /notifications/rules → crea regola
GET /notifications/rules → lista regole
GET /notifications/rules/{id} → dettaglio regola
PATCH /notifications/rules/{id} → aggiorna regola
DELETE /notifications/rules/{id} → elimina regola
GET /notifications/logs → log invii
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.notification import (
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelListResponse,
NotificationChannelResponse,
NotificationChannelUpdate,
NotificationLogListResponse,
NotificationLogResponse,
NotificationRuleCreate,
NotificationRuleListResponse,
NotificationRuleResponse,
NotificationRuleUpdate,
)
from app.services.notification_service import NotificationService
router = APIRouter(prefix="/notifications", tags=["Notifiche"])
# ─── Channels ─────────────────────────────────────────────────────────────────
@router.post(
"/channels",
response_model=NotificationChannelResponse,
status_code=201,
summary="Crea un canale di notifica",
)
async def create_channel(
body: NotificationChannelCreate,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.create_channel(current_user.tenant_id, body, current_user.id)
return NotificationChannelResponse.model_validate(channel)
@router.get(
"/channels",
response_model=NotificationChannelListResponse,
summary="Lista canali di notifica",
)
async def list_channels(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> NotificationChannelListResponse:
service = NotificationService(db)
items, total = await service.list_channels(current_user.tenant_id, page, page_size)
return NotificationChannelListResponse(
items=[NotificationChannelResponse.model_validate(c) for c in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/channels/{channel_id}",
response_model=NotificationChannelResponse,
summary="Dettaglio canale",
)
async def get_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.get_channel(channel_id, current_user.tenant_id)
return NotificationChannelResponse.model_validate(channel)
@router.patch(
"/channels/{channel_id}",
response_model=NotificationChannelResponse,
summary="Aggiorna canale",
)
async def update_channel(
channel_id: uuid.UUID,
body: NotificationChannelUpdate,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.update_channel(channel_id, current_user.tenant_id, body)
return NotificationChannelResponse.model_validate(channel)
@router.delete(
"/channels/{channel_id}",
status_code=204,
summary="Elimina canale",
)
async def delete_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = NotificationService(db)
await service.delete_channel(channel_id, current_user.tenant_id)
@router.post(
"/channels/{channel_id}/test",
response_model=ChannelTestResult,
summary="Test canale di notifica",
description="Invia un messaggio di test al canale per verificarne la configurazione.",
)
async def test_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> ChannelTestResult:
service = NotificationService(db)
return await service.test_channel(channel_id, current_user.tenant_id)
# ─── Rules ────────────────────────────────────────────────────────────────────
@router.post(
"/rules",
response_model=NotificationRuleResponse,
status_code=201,
summary="Crea una regola di notifica",
)
async def create_rule(
body: NotificationRuleCreate,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.create_rule(current_user.tenant_id, body)
return NotificationRuleResponse.model_validate(rule)
@router.get(
"/rules",
response_model=NotificationRuleListResponse,
summary="Lista regole di notifica",
)
async def list_rules(
current_user: AdminUser,
db: DB,
channel_id: uuid.UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> NotificationRuleListResponse:
service = NotificationService(db)
items, total = await service.list_rules(
current_user.tenant_id, channel_id, page, page_size
)
return NotificationRuleListResponse(
items=[NotificationRuleResponse.model_validate(r) for r in items],
total=total,
)
@router.get(
"/rules/{rule_id}",
response_model=NotificationRuleResponse,
summary="Dettaglio regola",
)
async def get_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.get_rule(rule_id, current_user.tenant_id)
return NotificationRuleResponse.model_validate(rule)
@router.patch(
"/rules/{rule_id}",
response_model=NotificationRuleResponse,
summary="Aggiorna regola",
)
async def update_rule(
rule_id: uuid.UUID,
body: NotificationRuleUpdate,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.update_rule(rule_id, current_user.tenant_id, body)
return NotificationRuleResponse.model_validate(rule)
@router.delete(
"/rules/{rule_id}",
status_code=204,
summary="Elimina regola",
)
async def delete_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = NotificationService(db)
await service.delete_rule(rule_id, current_user.tenant_id)
# ─── Logs ─────────────────────────────────────────────────────────────────────
@router.get(
"/logs",
response_model=NotificationLogListResponse,
summary="Log notifiche",
)
async def list_logs(
current_user: AdminUser,
db: DB,
channel_id: uuid.UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> NotificationLogListResponse:
service = NotificationService(db)
items, total = await service.list_logs(
current_user.tenant_id, channel_id, page, page_size
)
return NotificationLogListResponse(
items=[NotificationLogResponse.model_validate(l) for l in items],
total=total,
page=page,
page_size=page_size,
)
+276
View File
@@ -0,0 +1,276 @@
"""
Router Virtual Box (Fase 2).
Endpoint:
POST /virtual-boxes → crea VBox
GET /virtual-boxes → lista VBox del tenant
GET /virtual-boxes/{id} → dettaglio VBox
PATCH /virtual-boxes/{id} → aggiorna VBox (incluse caselle)
DELETE /virtual-boxes/{id} → elimina VBox
PUT /virtual-boxes/{id}/rules → sostituisce regole
PUT /virtual-boxes/{id}/mailboxes → imposta caselle reali associate
GET /virtual-boxes/{id}/mailboxes → lista caselle reali associate
POST /virtual-boxes/{id}/assignments → assegna utenti
DELETE /virtual-boxes/{id}/assignments/{user_id} → rimuovi assegnazione
GET /virtual-boxes/{id}/assignments → lista utenti assegnati
GET /virtual-boxes/my → VBox assegnate all'utente corrente
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.virtual_box import (
AssignedUserResponse,
MailboxBriefResponse,
VirtualBoxAssignmentResponse,
VirtualBoxAssignRequest,
VirtualBoxCreate,
VirtualBoxListResponse,
VirtualBoxMailboxAssignRequest,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VirtualBoxUpdate,
)
from app.services.virtual_box_service import VirtualBoxService
router = APIRouter(prefix="/virtual-boxes", tags=["Virtual Box"])
# ─── CRUD ─────────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=VirtualBoxResponse,
status_code=201,
summary="Crea una nuova Virtual Box",
)
async def create_virtual_box(
body: VirtualBoxCreate,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.create(current_user.tenant_id, body, current_user.id)
return _to_response(vbox)
@router.get(
"",
response_model=VirtualBoxListResponse,
summary="Lista Virtual Box del tenant",
)
async def list_virtual_boxes(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
active_only: bool = Query(False),
) -> VirtualBoxListResponse:
service = VirtualBoxService(db)
items, total = await service.list_vboxes(
current_user.tenant_id, page, page_size, active_only
)
return VirtualBoxListResponse(
items=[_to_response(v) for v in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/my",
response_model=list[VirtualBoxResponse],
summary="Virtual Box assegnate all'utente corrente",
)
async def my_virtual_boxes(
current_user: CurrentUser,
db: DB,
) -> list[VirtualBoxResponse]:
service = VirtualBoxService(db)
items = await service.list_user_vboxes(current_user.id, current_user.tenant_id)
return [_to_response(v) for v in items]
@router.get(
"/{vbox_id}",
response_model=VirtualBoxResponse,
summary="Dettaglio Virtual Box",
)
async def get_virtual_box(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.get(vbox_id, current_user.tenant_id)
return _to_response(vbox)
@router.patch(
"/{vbox_id}",
response_model=VirtualBoxResponse,
summary="Aggiorna Virtual Box",
description=(
"Aggiorna i metadati della Virtual Box. "
"Se `mailbox_ids` è fornito, sostituisce completamente le caselle PEC associate."
),
)
async def update_virtual_box(
vbox_id: uuid.UUID,
body: VirtualBoxUpdate,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.update(vbox_id, current_user.tenant_id, body)
return _to_response(vbox)
@router.delete(
"/{vbox_id}",
status_code=204,
summary="Elimina Virtual Box",
)
async def delete_virtual_box(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = VirtualBoxService(db)
await service.delete(vbox_id, current_user.tenant_id)
# ─── Regole ───────────────────────────────────────────────────────────────────
@router.put(
"/{vbox_id}/rules",
response_model=VirtualBoxResponse,
summary="Sostituisce le regole di una Virtual Box",
description="Rimpiazza completamente il set di regole. Inviare lista vuota per azzerarle.",
)
async def replace_rules(
vbox_id: uuid.UUID,
body: list[VirtualBoxRuleCreate],
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.replace_rules(vbox_id, current_user.tenant_id, body)
return _to_response(vbox)
# ─── Caselle Reali ────────────────────────────────────────────────────────────
@router.put(
"/{vbox_id}/mailboxes",
response_model=VirtualBoxResponse,
summary="Imposta le caselle PEC reali associate alla Virtual Box",
description=(
"Sostituisce completamente l'elenco delle caselle PEC reali collegate. "
"Inviare una lista vuota per rimuovere tutte le associazioni."
),
)
async def set_mailboxes(
vbox_id: uuid.UUID,
body: VirtualBoxMailboxAssignRequest,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.set_mailboxes(vbox_id, current_user.tenant_id, body.mailbox_ids)
return _to_response(vbox)
@router.get(
"/{vbox_id}/mailboxes",
response_model=list[MailboxBriefResponse],
summary="Lista caselle PEC reali associate alla Virtual Box",
)
async def list_mailboxes(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[MailboxBriefResponse]:
service = VirtualBoxService(db)
mailboxes = await service.list_mailboxes(vbox_id, current_user.tenant_id)
return [MailboxBriefResponse.model_validate(m) for m in mailboxes]
# ─── Assegnazioni ─────────────────────────────────────────────────────────────
@router.post(
"/{vbox_id}/assignments",
response_model=list[VirtualBoxAssignmentResponse],
status_code=201,
summary="Assegna utenti a una Virtual Box",
)
async def assign_users(
vbox_id: uuid.UUID,
body: VirtualBoxAssignRequest,
current_user: AdminUser,
db: DB,
) -> list[VirtualBoxAssignmentResponse]:
service = VirtualBoxService(db)
assignments = await service.assign_users(
vbox_id, current_user.tenant_id, body.user_ids, current_user.id
)
return [VirtualBoxAssignmentResponse.model_validate(a) for a in assignments]
@router.delete(
"/{vbox_id}/assignments/{user_id}",
status_code=204,
summary="Rimuovi assegnazione utente",
)
async def unassign_user(
vbox_id: uuid.UUID,
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = VirtualBoxService(db)
await service.unassign_user(vbox_id, current_user.tenant_id, user_id)
@router.get(
"/{vbox_id}/assignments",
response_model=list[AssignedUserResponse],
summary="Lista utenti assegnati a una Virtual Box",
)
async def list_assigned_users(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[AssignedUserResponse]:
service = VirtualBoxService(db)
rows = await service.list_assigned_users(vbox_id, current_user.tenant_id)
return [AssignedUserResponse(**row) for row in rows]
# ─── Helper ───────────────────────────────────────────────────────────────────
def _to_response(vbox) -> VirtualBoxResponse:
"""Costruisce la risposta includendo regole, conteggio assegnazioni e caselle reali."""
from app.schemas.virtual_box import VirtualBoxRuleResponse
rules = [VirtualBoxRuleResponse.model_validate(r) for r in (vbox.rules or [])]
mailboxes = [
MailboxBriefResponse.model_validate(m) for m in (vbox.mailboxes or [])
]
return VirtualBoxResponse(
id=vbox.id,
tenant_id=vbox.tenant_id,
name=vbox.name,
description=vbox.description,
label=vbox.label,
is_active=vbox.is_active,
created_by=vbox.created_by,
created_at=vbox.created_at,
updated_at=vbox.updated_at,
rules=rules,
assignment_count=len(vbox.assignments or []),
mailboxes=mailboxes,
)