mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
vbox funzionanti
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""Add sent_last_sync_uid to mailboxes – sincronizzazione cartella Sent IMAP
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-03-19 09:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE mailboxes
|
||||
ADD COLUMN IF NOT EXISTS sent_last_sync_uid BIGINT
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE mailboxes
|
||||
DROP COLUMN IF EXISTS sent_last_sync_uid
|
||||
""")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Virtual Boxes e Notifiche Multi-canale – Fase 2
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-03-19 00:00:00.000000
|
||||
|
||||
Aggiunge le tabelle:
|
||||
- virtual_boxes
|
||||
- virtual_box_rules
|
||||
- virtual_box_assignments
|
||||
- notification_channels (+ ENUM notification_channel_type)
|
||||
- notification_rules
|
||||
- notification_log (+ ENUM notification_status)
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ENUM types ────────────────────────────────────────────────────────────
|
||||
op.execute(
|
||||
"CREATE TYPE notification_channel_type AS ENUM "
|
||||
"('webhook', 'email', 'telegram', 'whatsapp')"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE TYPE notification_status AS ENUM "
|
||||
"('pending', 'sent', 'failed', 'skipped')"
|
||||
)
|
||||
|
||||
# ── VIRTUAL BOXES ─────────────────────────────────────────────────────────
|
||||
op.execute("""
|
||||
CREATE TABLE virtual_boxes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
label VARCHAR(100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_vbox_name_tenant UNIQUE (tenant_id, name)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_vbox_tenant ON virtual_boxes (tenant_id)")
|
||||
op.execute("""
|
||||
CREATE TRIGGER trg_virtual_boxes_updated_at
|
||||
BEFORE UPDATE ON virtual_boxes
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE virtual_box_rules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
|
||||
field VARCHAR(50) NOT NULL,
|
||||
operator VARCHAR(20) NOT NULL DEFAULT 'contains',
|
||||
value TEXT NOT NULL,
|
||||
date_from VARCHAR(20),
|
||||
date_to VARCHAR(20),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_vbox_rule_vbox ON virtual_box_rules (virtual_box_id)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE virtual_box_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_vbox_assignment UNIQUE (virtual_box_id, user_id)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_vbox_assign_user ON virtual_box_assignments (user_id)")
|
||||
op.execute("CREATE INDEX idx_vbox_assign_vbox ON virtual_box_assignments (virtual_box_id)")
|
||||
|
||||
# ── NOTIFICATION CHANNELS ─────────────────────────────────────────────────
|
||||
op.execute("""
|
||||
CREATE TABLE notification_channels (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
channel_type notification_channel_type NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config JSONB,
|
||||
config_enc TEXT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
circuit_open_until TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_notif_channel_tenant ON notification_channels (tenant_id)")
|
||||
op.execute("""
|
||||
CREATE TRIGGER trg_notification_channels_updated_at
|
||||
BEFORE UPDATE ON notification_channels
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE notification_rules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
filter JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_notif_rule_tenant ON notification_rules (tenant_id)")
|
||||
op.execute("CREATE INDEX idx_notif_rule_channel ON notification_rules (channel_id)")
|
||||
op.execute("CREATE INDEX idx_notif_rule_event ON notification_rules (event_type)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE notification_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
rule_id UUID REFERENCES notification_rules(id) ON DELETE SET NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
event_payload JSONB,
|
||||
status notification_status NOT NULL DEFAULT 'pending',
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 3,
|
||||
next_retry_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
http_status INT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_notif_log_tenant ON notification_log (tenant_id)")
|
||||
op.execute("CREATE INDEX idx_notif_log_channel ON notification_log (channel_id)")
|
||||
op.execute(
|
||||
"CREATE INDEX idx_notif_log_status ON notification_log (status, next_retry_at) "
|
||||
"WHERE status IN ('pending', 'failed')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in [
|
||||
"notification_log",
|
||||
"notification_rules",
|
||||
"notification_channels",
|
||||
"virtual_box_assignments",
|
||||
"virtual_box_rules",
|
||||
"virtual_boxes",
|
||||
]:
|
||||
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
|
||||
|
||||
op.execute("DROP TYPE IF EXISTS notification_status")
|
||||
op.execute("DROP TYPE IF EXISTS notification_channel_type")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Aggiunge la tabella di associazione virtual_box_mailboxes
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-03-19 00:00:00.000000
|
||||
|
||||
Aggiunge:
|
||||
- virtual_box_mailboxes (associazione many-to-many tra virtual_boxes e mailboxes)
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE virtual_box_mailboxes (
|
||||
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
|
||||
mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (virtual_box_id, mailbox_id)
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vbox_mbox_vbox ON virtual_box_mailboxes (virtual_box_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vbox_mbox_mailbox ON virtual_box_mailboxes (mailbox_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS virtual_box_mailboxes CASCADE")
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
+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 auth, mailboxes, messages, permissions, send, tenants, users, ws
|
||||
from app.api.v1 import auth, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
@@ -91,6 +91,8 @@ app.include_router(mailboxes.router, prefix=API_PREFIX)
|
||||
app.include_router(messages.router, prefix=API_PREFIX)
|
||||
app.include_router(send.router, prefix=API_PREFIX)
|
||||
app.include_router(ws.router, prefix=API_PREFIX)
|
||||
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
||||
app.include_router(notifications.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,3 +7,5 @@ from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip
|
||||
from app.models.audit_log import AuditLog # noqa: F401
|
||||
from app.models.label import Label, MessageLabel # noqa: F401
|
||||
from app.models.permission import MailboxPermission # noqa: F401
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
|
||||
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
|
||||
|
||||
@@ -61,6 +61,7 @@ class Mailbox(Base):
|
||||
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
sent_last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Modelli Notifiche Multi-canale.
|
||||
|
||||
Struttura:
|
||||
NotificationChannel → configurazione canale (Webhook, Email, Telegram, WhatsApp)
|
||||
NotificationRule → regola evento → canale
|
||||
NotificationLog → log tentativi di invio (con retry e circuit breaker)
|
||||
|
||||
Canali supportati:
|
||||
webhook – POST HMAC-SHA256
|
||||
email – SMTP
|
||||
telegram – Bot API
|
||||
whatsapp – Meta Cloud API v18
|
||||
|
||||
Sicurezza:
|
||||
config_enc – configurazione sensibile cifrata AES-256-GCM
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
ChannelType = Enum(
|
||||
"webhook", "email", "telegram", "whatsapp",
|
||||
name="notification_channel_type",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
NotificationStatus = Enum(
|
||||
"pending", "sent", "failed", "skipped",
|
||||
name="notification_status",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
"""
|
||||
Canale di notifica configurato da un tenant.
|
||||
|
||||
Il campo config_enc contiene la configurazione sensibile (API key,
|
||||
token, SMTP password…) cifrata AES-256-GCM a livello applicativo.
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_channels"
|
||||
|
||||
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(255), nullable=False)
|
||||
channel_type: Mapped[str] = mapped_column(ChannelType, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Configurazione NON sensibile (JSON libero: url, chat_id, from_email…)
|
||||
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Configurazione sensibile cifrata (token, password…) – base64(GCM(json))
|
||||
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Circuit breaker
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
circuit_open_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), 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
|
||||
rules: Mapped[list["NotificationRule"]] = relationship(
|
||||
"NotificationRule", back_populates="channel", cascade="all, delete-orphan"
|
||||
)
|
||||
logs: Mapped[list["NotificationLog"]] = relationship(
|
||||
"NotificationLog", back_populates="channel", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_notif_channel_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NotificationChannel {self.name!r} type={self.channel_type!r}>"
|
||||
|
||||
|
||||
class NotificationRule(Base):
|
||||
"""
|
||||
Regola: evento PecFlow → canale di notifica.
|
||||
|
||||
event_type: new_message | state_changed | anomaly | send_failed | ...
|
||||
filter: JSONB con condizioni opzionali (mailbox_id, state, ecc.)
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_rules"
|
||||
|
||||
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
|
||||
)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
channel: Mapped["NotificationChannel"] = relationship(
|
||||
"NotificationChannel", back_populates="rules"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_notif_rule_tenant", "tenant_id"),
|
||||
Index("idx_notif_rule_channel", "channel_id"),
|
||||
Index("idx_notif_rule_event", "event_type"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NotificationRule {self.name!r} event={self.event_type!r}>"
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
"""
|
||||
Log di ogni tentativo di notifica.
|
||||
|
||||
Retry indipendente per canale: max 3 tentativi, backoff 5m → 30m → 2h.
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
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
|
||||
)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
rule_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_rules.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(NotificationStatus, nullable=False, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
sent_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
channel: Mapped["NotificationChannel"] = relationship(
|
||||
"NotificationChannel", back_populates="logs"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_notif_log_tenant", "tenant_id"),
|
||||
Index("idx_notif_log_channel", "channel_id"),
|
||||
Index("idx_notif_log_status", "status", "next_retry_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NotificationLog channel={self.channel_id} status={self.status!r}>"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Modelli Virtual Box – filtri nominati assegnabili agli utenti.
|
||||
|
||||
Struttura:
|
||||
VirtualBox → definisce il filtro (nome, label, mailbox scope)
|
||||
VirtualBoxRule → singola regola (field + pattern) dentro una VBox
|
||||
VirtualBoxAssignment → assegnazione VBox → User
|
||||
|
||||
Logica:
|
||||
- Le regole nella stessa VBox si combinano in AND.
|
||||
- Più VBox assegnate allo stesso utente si uniscono in OR.
|
||||
- Il filtro si applica automaticamente a inbox e ricerca.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# ─── Tabella di associazione VirtualBox ↔ Mailbox ─────────────────────────────
|
||||
virtual_box_mailboxes = Table(
|
||||
"virtual_box_mailboxes",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"virtual_box_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"mailbox_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("mailboxes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class VirtualBox(Base):
|
||||
"""Casella virtuale: contenitore di regole + etichetta opzionale."""
|
||||
|
||||
__tablename__ = "virtual_boxes"
|
||||
|
||||
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(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
label: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), 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
|
||||
rules: Mapped[list["VirtualBoxRule"]] = relationship(
|
||||
"VirtualBoxRule", back_populates="virtual_box", cascade="all, delete-orphan",
|
||||
order_by="VirtualBoxRule.created_at"
|
||||
)
|
||||
assignments: Mapped[list["VirtualBoxAssignment"]] = relationship(
|
||||
"VirtualBoxAssignment", back_populates="virtual_box", cascade="all, delete-orphan"
|
||||
)
|
||||
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
|
||||
"Mailbox",
|
||||
secondary=virtual_box_mailboxes,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_vbox_name_tenant"),
|
||||
Index("idx_vbox_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VirtualBox {self.name!r}>"
|
||||
|
||||
|
||||
class VirtualBoxRule(Base):
|
||||
"""
|
||||
Singola regola di filtro all'interno di una VBox.
|
||||
|
||||
field : mailbox_id | imap_folder | subject | from_address | to_address
|
||||
operator : contains | equals | starts_with | ends_with | regex
|
||||
value : valore da confrontare
|
||||
|
||||
Può anche specificare un date_from / date_to per filtrare per periodo.
|
||||
"""
|
||||
|
||||
__tablename__ = "virtual_box_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
field: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
operator: Mapped[str] = mapped_column(String(20), nullable=False, default="contains")
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
date_from: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
|
||||
date_to: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
virtual_box: Mapped["VirtualBox"] = relationship(
|
||||
"VirtualBox", back_populates="rules"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_vbox_rule_vbox", "virtual_box_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VirtualBoxRule {self.field!r} {self.operator!r} {self.value!r}>"
|
||||
|
||||
|
||||
class VirtualBoxAssignment(Base):
|
||||
"""Assegnazione di una VBox a un utente."""
|
||||
|
||||
__tablename__ = "virtual_box_assignments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
assigned_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
assigned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
virtual_box: Mapped["VirtualBox"] = relationship(
|
||||
"VirtualBox", back_populates="assignments"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("virtual_box_id", "user_id", name="uq_vbox_assignment"),
|
||||
Index("idx_vbox_assign_user", "user_id"),
|
||||
Index("idx_vbox_assign_vbox", "virtual_box_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<VirtualBoxAssignment vbox={self.virtual_box_id} user={self.user_id}>"
|
||||
@@ -81,3 +81,14 @@ class MessageUpdateRequest(BaseModel):
|
||||
is_read: Optional[bool] = None
|
||||
is_starred: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateRequest(BaseModel):
|
||||
ids: list[uuid.UUID]
|
||||
is_starred: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateResponse(BaseModel):
|
||||
updated: int
|
||||
items: list[MessageResponse]
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Schema Pydantic per i canali di notifica multi-canale.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── NotificationChannel ─────────────────────────────────────────────────────
|
||||
|
||||
ChannelType = Literal["webhook", "email", "telegram", "whatsapp"]
|
||||
|
||||
|
||||
class NotificationChannelCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
channel_type: ChannelType
|
||||
config: dict[str, Any] | None = None # configurazione pubblica
|
||||
config_secret: dict[str, Any] | None = None # verrà cifrato in config_enc
|
||||
|
||||
|
||||
class NotificationChannelUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
is_active: bool | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
config_secret: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class NotificationChannelResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
channel_type: str
|
||||
is_active: bool
|
||||
config: dict[str, Any] | None
|
||||
consecutive_failures: int
|
||||
circuit_open_until: datetime | None
|
||||
created_by: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationChannelListResponse(BaseModel):
|
||||
items: list[NotificationChannelResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ChannelTestResult(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
http_status: int | None = None
|
||||
|
||||
|
||||
# ─── NotificationRule ─────────────────────────────────────────────────────────
|
||||
|
||||
EventType = Literal[
|
||||
"new_message",
|
||||
"state_changed",
|
||||
"anomaly",
|
||||
"send_failed",
|
||||
"send_delivered",
|
||||
"mailbox_error",
|
||||
]
|
||||
|
||||
|
||||
class NotificationRuleCreate(BaseModel):
|
||||
channel_id: uuid.UUID
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
event_type: EventType
|
||||
filter: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class NotificationRuleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
event_type: EventType | None = None
|
||||
filter: dict[str, Any] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class NotificationRuleResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
channel_id: uuid.UUID
|
||||
name: str
|
||||
event_type: str
|
||||
filter: dict[str, Any] | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationRuleListResponse(BaseModel):
|
||||
items: list[NotificationRuleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── NotificationLog ─────────────────────────────────────────────────────────
|
||||
|
||||
class NotificationLogResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
channel_id: uuid.UUID
|
||||
rule_id: uuid.UUID | None
|
||||
event_type: str
|
||||
status: str
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
next_retry_at: datetime | None
|
||||
last_error: str | None
|
||||
http_status: int | None
|
||||
sent_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationLogListResponse(BaseModel):
|
||||
items: list[NotificationLogResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Schema Pydantic per le Virtual Box.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── VirtualBoxRule ───────────────────────────────────────────────────────────
|
||||
|
||||
VBoxField = Literal["mailbox_id", "imap_folder", "subject", "from_address", "to_address"]
|
||||
VBoxOperator = Literal["contains", "equals", "starts_with", "ends_with", "regex"]
|
||||
|
||||
|
||||
class VirtualBoxRuleCreate(BaseModel):
|
||||
field: VBoxField
|
||||
operator: VBoxOperator = "contains"
|
||||
value: str = Field(..., min_length=1)
|
||||
date_from: str | None = None # YYYY-MM-DD
|
||||
date_to: str | None = None # YYYY-MM-DD
|
||||
|
||||
|
||||
class VirtualBoxRuleResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
virtual_box_id: uuid.UUID
|
||||
field: str
|
||||
operator: str
|
||||
value: str
|
||||
date_from: str | None
|
||||
date_to: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Mailbox breve (usata nella risposta VirtualBox) ─────────────────────────
|
||||
|
||||
class MailboxBriefResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
email_address: str
|
||||
display_name: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── VirtualBox ───────────────────────────────────────────────────────────────
|
||||
|
||||
class VirtualBoxCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
label: str | None = None
|
||||
rules: list[VirtualBoxRuleCreate] = []
|
||||
mailbox_ids: list[uuid.UUID] = Field(
|
||||
default=[],
|
||||
description="ID delle caselle PEC reali a cui è associata questa Virtual Box",
|
||||
)
|
||||
|
||||
|
||||
class VirtualBoxUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
label: str | None = None
|
||||
is_active: bool | None = None
|
||||
mailbox_ids: list[uuid.UUID] | None = Field(
|
||||
default=None,
|
||||
description="Se fornito, sostituisce completamente la lista di caselle associate",
|
||||
)
|
||||
|
||||
|
||||
class VirtualBoxResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
label: str | None
|
||||
is_active: bool
|
||||
created_by: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
rules: list[VirtualBoxRuleResponse] = []
|
||||
assignment_count: int = 0
|
||||
mailboxes: list[MailboxBriefResponse] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class VirtualBoxListResponse(BaseModel):
|
||||
items: list[VirtualBoxResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ─── VirtualBoxAssignment ─────────────────────────────────────────────────────
|
||||
|
||||
class VirtualBoxAssignRequest(BaseModel):
|
||||
user_ids: list[uuid.UUID] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class VirtualBoxAssignmentResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
virtual_box_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
assigned_by: uuid.UUID | None
|
||||
assigned_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssignedUserResponse(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
user_email: str
|
||||
user_full_name: str
|
||||
assigned_at: datetime
|
||||
|
||||
|
||||
# ─── Gestione caselle associate ───────────────────────────────────────────────
|
||||
|
||||
class VirtualBoxMailboxAssignRequest(BaseModel):
|
||||
mailbox_ids: list[uuid.UUID] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Lista degli ID delle caselle PEC da associare",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user