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
@@ -0,0 +1,275 @@
"""
Servizio Notifiche Multi-canale CRUD canali, regole, log.
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
con una implementazione GCM dedicata.
"""
import base64
import json
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import NotFoundError
from app.models.notification import NotificationChannel, NotificationLog, NotificationRule
from app.schemas.notification import (
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelUpdate,
NotificationRuleCreate,
NotificationRuleUpdate,
)
settings = get_settings()
def _encrypt(data: dict) -> str:
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
raw = json.dumps(data).encode()
return base64.b64encode(raw).decode()
def _decrypt(enc: str) -> dict:
"""Decifra il valore restituito da _encrypt."""
raw = base64.b64decode(enc.encode())
return json.loads(raw.decode())
class NotificationService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Channels ────────────────────────────────────────────────────────────
async def create_channel(
self,
tenant_id: uuid.UUID,
data: NotificationChannelCreate,
created_by: uuid.UUID,
) -> NotificationChannel:
config_enc = None
if data.config_secret:
config_enc = _encrypt(data.config_secret)
channel = NotificationChannel(
tenant_id=tenant_id,
name=data.name,
channel_type=data.channel_type,
config=data.config,
config_enc=config_enc,
created_by=created_by,
)
self.db.add(channel)
await self.db.flush()
return channel
async def list_channels(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> tuple[list[NotificationChannel], int]:
query = select(NotificationChannel).where(
NotificationChannel.tenant_id == tenant_id
).order_by(NotificationChannel.created_at.desc())
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
async def get_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> NotificationChannel:
channel = await self.db.get(NotificationChannel, channel_id)
if not channel or channel.tenant_id != tenant_id:
raise NotFoundError("canale di notifica")
return channel
async def update_channel(
self,
channel_id: uuid.UUID,
tenant_id: uuid.UUID,
data: NotificationChannelUpdate,
) -> NotificationChannel:
channel = await self.get_channel(channel_id, tenant_id)
if data.name is not None:
channel.name = data.name
if data.is_active is not None:
channel.is_active = data.is_active
if data.config is not None:
channel.config = data.config
if data.config_secret is not None:
channel.config_enc = _encrypt(data.config_secret)
await self.db.flush()
return channel
async def delete_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> None:
channel = await self.get_channel(channel_id, tenant_id)
await self.db.delete(channel)
async def test_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> ChannelTestResult:
"""
Invia un messaggio di test al canale configurato.
Questa implementazione stub restituisce sempre successo se il canale
è attivo e configurato. Una implementazione completa fa una chiamata
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
"""
channel = await self.get_channel(channel_id, tenant_id)
if not channel.is_active:
return ChannelTestResult(
success=False,
message="Il canale è disabilitato",
)
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
return ChannelTestResult(
success=False,
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
)
# Validazione configurazione minima per tipo canale
config = channel.config or {}
channel_type = channel.channel_type
if channel_type == "webhook":
if not config.get("url"):
return ChannelTestResult(success=False, message="URL webhook non configurato")
elif channel_type == "email":
if not config.get("to_email"):
return ChannelTestResult(success=False, message="Email destinatario non configurata")
elif channel_type == "telegram":
if not config.get("chat_id"):
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
elif channel_type == "whatsapp":
if not config.get("phone_number"):
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
return ChannelTestResult(
success=True,
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
http_status=200,
)
# ─── Rules ───────────────────────────────────────────────────────────────
async def create_rule(
self,
tenant_id: uuid.UUID,
data: NotificationRuleCreate,
) -> NotificationRule:
# Verifica che il canale appartenga al tenant
await self.get_channel(data.channel_id, tenant_id)
rule = NotificationRule(
tenant_id=tenant_id,
channel_id=data.channel_id,
name=data.name,
event_type=data.event_type,
filter=data.filter,
)
self.db.add(rule)
await self.db.flush()
return rule
async def list_rules(
self,
tenant_id: uuid.UUID,
channel_id: uuid.UUID | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[NotificationRule], int]:
query = select(NotificationRule).where(
NotificationRule.tenant_id == tenant_id
).order_by(NotificationRule.created_at.desc())
if channel_id:
query = query.where(NotificationRule.channel_id == channel_id)
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
async def get_rule(
self, rule_id: uuid.UUID, tenant_id: uuid.UUID
) -> NotificationRule:
rule = await self.db.get(NotificationRule, rule_id)
if not rule or rule.tenant_id != tenant_id:
raise NotFoundError("regola di notifica")
return rule
async def update_rule(
self,
rule_id: uuid.UUID,
tenant_id: uuid.UUID,
data: NotificationRuleUpdate,
) -> NotificationRule:
rule = await self.get_rule(rule_id, tenant_id)
if data.name is not None:
rule.name = data.name
if data.event_type is not None:
rule.event_type = data.event_type
if data.filter is not None:
rule.filter = data.filter
if data.is_active is not None:
rule.is_active = data.is_active
await self.db.flush()
return rule
async def delete_rule(
self, rule_id: uuid.UUID, tenant_id: uuid.UUID
) -> None:
rule = await self.get_rule(rule_id, tenant_id)
await self.db.delete(rule)
# ─── Logs ────────────────────────────────────────────────────────────────
async def list_logs(
self,
tenant_id: uuid.UUID,
channel_id: uuid.UUID | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[NotificationLog], int]:
query = select(NotificationLog).where(
NotificationLog.tenant_id == tenant_id
).order_by(NotificationLog.created_at.desc())
if channel_id:
query = query.where(NotificationLog.channel_id == channel_id)
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
+348
View File
@@ -0,0 +1,348 @@
"""
Servizio Virtual Box CRUD, gestione assegnazioni utente e caselle reali.
"""
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.models.mailbox import Mailbox
from app.models.user import User
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule
from app.schemas.virtual_box import (
AssignedUserResponse,
VirtualBoxCreate,
VirtualBoxResponse,
VirtualBoxRuleResponse,
VirtualBoxUpdate,
)
class VirtualBoxService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── CRUD VirtualBox ──────────────────────────────────────────────────────
async def create(
self,
tenant_id: uuid.UUID,
data: VirtualBoxCreate,
created_by: uuid.UUID,
) -> VirtualBox:
# Verifica unicità nome nel tenant
existing = await self.db.execute(
select(VirtualBox).where(
VirtualBox.tenant_id == tenant_id,
VirtualBox.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Virtual Box con nome '{data.name}' già esistente")
vbox = VirtualBox(
tenant_id=tenant_id,
name=data.name,
description=data.description,
label=data.label,
created_by=created_by,
)
self.db.add(vbox)
await self.db.flush()
# Crea le regole
for rule_data in data.rules:
rule = VirtualBoxRule(
virtual_box_id=vbox.id,
field=rule_data.field,
operator=rule_data.operator,
value=rule_data.value,
date_from=rule_data.date_from,
date_to=rule_data.date_to,
)
self.db.add(rule)
# Associa le caselle reali
if data.mailbox_ids:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.flush()
return await self._load_full(vbox.id)
async def list_vboxes(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
active_only: bool = False,
) -> tuple[list[VirtualBox], int]:
query = select(VirtualBox).where(VirtualBox.tenant_id == tenant_id)
if active_only:
query = query.where(VirtualBox.is_active == True)
query = query.order_by(VirtualBox.created_at.desc())
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
query = query.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.assignments),
selectinload(VirtualBox.mailboxes),
)
result = await self.db.execute(query)
items = list(result.scalars().all())
return items, total
async def get(self, vbox_id: uuid.UUID, tenant_id: uuid.UUID) -> VirtualBox:
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
return vbox
async def update(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
data: VirtualBoxUpdate,
) -> VirtualBox:
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
if data.name is not None:
# Verifica unicità nuovo nome
existing = await self.db.execute(
select(VirtualBox).where(
VirtualBox.tenant_id == tenant_id,
VirtualBox.name == data.name,
VirtualBox.id != vbox_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Virtual Box con nome '{data.name}' già esistente")
vbox.name = data.name
if data.description is not None:
vbox.description = data.description
if data.label is not None:
vbox.label = data.label
if data.is_active is not None:
vbox.is_active = data.is_active
# Aggiorna le caselle associate se fornito
if data.mailbox_ids is not None:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.flush()
return await self._load_full(vbox_id)
async def delete(self, vbox_id: uuid.UUID, tenant_id: uuid.UUID) -> None:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
await self.db.delete(vbox)
# ─── Gestione Regole ─────────────────────────────────────────────────────
async def replace_rules(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
rules_data: list,
) -> VirtualBox:
"""Sostituisce tutte le regole di una VBox."""
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
# Rimuovi regole esistenti
await self.db.execute(
delete(VirtualBoxRule).where(VirtualBoxRule.virtual_box_id == vbox_id)
)
# Aggiungi nuove regole
for rule_data in rules_data:
rule = VirtualBoxRule(
virtual_box_id=vbox_id,
field=rule_data.field,
operator=rule_data.operator,
value=rule_data.value,
date_from=rule_data.date_from,
date_to=rule_data.date_to,
)
self.db.add(rule)
await self.db.flush()
return await self._load_full(vbox_id)
# ─── Gestione Caselle Reali ───────────────────────────────────────────────
async def set_mailboxes(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
mailbox_ids: list[uuid.UUID],
) -> VirtualBox:
"""Sostituisce completamente le caselle associate a una VBox."""
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.flush()
return await self._load_full(vbox_id)
async def list_mailboxes(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[Mailbox]:
"""Restituisce le caselle associate a una VBox."""
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
return vbox.mailboxes or []
# ─── Gestione Assegnazioni ────────────────────────────────────────────────
async def assign_users(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
user_ids: list[uuid.UUID],
assigned_by: uuid.UUID,
) -> list[VirtualBoxAssignment]:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
# Recupera assegnazioni esistenti
existing_result = await self.db.execute(
select(VirtualBoxAssignment.user_id).where(
VirtualBoxAssignment.virtual_box_id == vbox_id
)
)
existing_user_ids = {row[0] for row in existing_result.all()}
new_assignments = []
for user_id in user_ids:
if user_id not in existing_user_ids:
# Verifica che l'utente esista e appartenga al tenant
user = await self.db.get(User, user_id)
if not user or user.tenant_id != tenant_id:
continue
assignment = VirtualBoxAssignment(
virtual_box_id=vbox_id,
user_id=user_id,
assigned_by=assigned_by,
)
self.db.add(assignment)
new_assignments.append(assignment)
await self.db.flush()
return new_assignments
async def unassign_user(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
result = await self.db.execute(
delete(VirtualBoxAssignment).where(
VirtualBoxAssignment.virtual_box_id == vbox_id,
VirtualBoxAssignment.user_id == user_id,
)
)
if result.rowcount == 0:
raise NotFoundError("assegnazione")
async def list_assigned_users(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[dict]:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
result = await self.db.execute(
select(VirtualBoxAssignment, User)
.join(User, VirtualBoxAssignment.user_id == User.id)
.where(VirtualBoxAssignment.virtual_box_id == vbox_id)
)
return [
{
"user_id": assignment.user_id,
"user_email": user.email,
"user_full_name": user.full_name,
"assigned_at": assignment.assigned_at,
}
for assignment, user in result.all()
]
async def list_user_vboxes(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[VirtualBox]:
"""Restituisce le VBox assegnate a un utente specifico."""
result = await self.db.execute(
select(VirtualBox)
.join(VirtualBoxAssignment, VirtualBox.id == VirtualBoxAssignment.virtual_box_id)
.where(
VirtualBoxAssignment.user_id == user_id,
VirtualBox.tenant_id == tenant_id,
VirtualBox.is_active == True,
)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.mailboxes),
selectinload(VirtualBox.assignments), # necessario per _to_response()
)
)
return list(result.scalars().all())
# ─── Private ─────────────────────────────────────────────────────────────
async def _load_full(self, vbox_id: uuid.UUID) -> VirtualBox | None:
result = await self.db.execute(
select(VirtualBox)
.where(VirtualBox.id == vbox_id)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.assignments),
selectinload(VirtualBox.mailboxes),
)
)
return result.scalar_one_or_none()
async def _load_mailboxes(
self,
mailbox_ids: list[uuid.UUID],
tenant_id: uuid.UUID,
) -> list[Mailbox]:
"""Carica le mailbox dal DB, filtrando per tenant."""
if not mailbox_ids:
return []
result = await self.db.execute(
select(Mailbox).where(
Mailbox.id.in_(mailbox_ids),
Mailbox.tenant_id == tenant_id,
)
)
return list(result.scalars().all())