Files
PecHub/backend/app/services/routing_rule_service.py
T
2026-03-27 20:59:06 +01:00

297 lines
10 KiB
Python

"""
Service per la gestione delle regole di smistamento automatico (Feature 2).
Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound.
"""
import re
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.exceptions import NotFoundError
from app.models.label import Label, MessageLabel
from app.models.message import Message
from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition
from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate
class RoutingRuleService:
def __init__(self, db: AsyncSession):
self.db = db
# ─── CRUD ─────────────────────────────────────────────────────────────────
async def list_rules(
self,
tenant_id: uuid.UUID,
) -> tuple[list[RoutingRule], int]:
query = (
select(RoutingRule)
.where(RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
count_q = select(func.count()).select_from(
select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery()
)
total = (await self.db.execute(count_q)).scalar_one()
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> RoutingRule:
result = await self.db.execute(
select(RoutingRule)
.where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
)
rule = result.scalar_one_or_none()
if not rule:
raise NotFoundError(f"Regola {rule_id} non trovata")
return rule
async def create_rule(
self,
tenant_id: uuid.UUID,
data: RoutingRuleCreate,
created_by: uuid.UUID | None = None,
) -> RoutingRule:
rule = RoutingRule(
tenant_id=tenant_id,
name=data.name,
description=data.description,
is_active=data.is_active,
priority=data.priority,
stop_processing=data.stop_processing,
created_by=created_by,
)
self.db.add(rule)
await self.db.flush()
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule.id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule.id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule.id)
async def update_rule(
self,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
data: RoutingRuleUpdate,
) -> RoutingRule:
rule = await self.get_rule(tenant_id, rule_id)
if data.name is not None:
rule.name = data.name
if data.description is not None:
rule.description = data.description
if data.is_active is not None:
rule.is_active = data.is_active
if data.priority is not None:
rule.priority = data.priority
if data.stop_processing is not None:
rule.stop_processing = data.stop_processing
# Se condizioni o azioni vengono aggiornate, le sostituisce completamente
if data.conditions is not None:
await self.db.execute(
delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id)
)
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule_id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
if data.actions is not None:
await self.db.execute(
delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id)
)
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule_id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule_id)
async def delete_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> None:
rule = await self.get_rule(tenant_id, rule_id)
await self.db.delete(rule)
await self.db.commit()
# ─── Motore di valutazione ────────────────────────────────────────────────
async def evaluate_and_apply(
self,
message: Message,
) -> int:
"""
Valuta le regole attive del tenant e applica le azioni su message.
Returns:
Numero di regole che hanno prodotto match.
"""
# Carica regole attive ordinate per priority
result = await self.db.execute(
select(RoutingRule)
.where(
RoutingRule.tenant_id == message.tenant_id,
RoutingRule.is_active == True, # noqa: E712
)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
rules = list(result.scalars().all())
matched_count = 0
for rule in rules:
if await self._matches(message, rule.conditions):
matched_count += 1
await self._apply_actions(message, rule.actions)
if rule.stop_processing:
break
if matched_count > 0:
await self.db.flush()
return matched_count
async def _matches(
self,
message: Message,
conditions: list[RoutingRuleCondition],
) -> bool:
"""Restituisce True se tutte le condizioni (AND) sono soddisfatte."""
if not conditions:
# Una regola senza condizioni non fa mai match (comportamento sicuro)
return False
for cond in conditions:
field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False
return True
def _get_field_value(self, message: Message, field: str) -> str:
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
if field == "from_address":
return (message.from_address or "").lower()
elif field == "to_address":
return " ".join(message.to_addresses or []).lower()
elif field == "subject":
return (message.subject or "").lower()
elif field == "mailbox_id":
return str(message.mailbox_id)
elif field == "pec_type":
return message.pec_type or ""
return ""
def _evaluate_condition(
self, field_value: str, operator: str, value: str
) -> bool:
v = value.lower()
fv = field_value.lower()
if operator == "contains":
return v in fv
elif operator == "not_contains":
return v not in fv
elif operator == "equals":
return fv == v
elif operator == "starts_with":
return fv.startswith(v)
elif operator == "ends_with":
return fv.endswith(v)
elif operator == "regex":
try:
return bool(re.search(value, field_value, re.IGNORECASE))
except re.error:
return False
return False
async def _apply_actions(
self,
message: Message,
actions: list[RoutingRuleAction],
) -> None:
"""Esegue le azioni sulla regola che ha fatto match."""
for action in actions:
try:
if action.action_type == "apply_label" and action.action_value:
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "mark_read":
message.is_read = True
elif action.action_type == "mark_starred":
message.is_starred = True
elif action.action_type == "notify_webhook" and action.action_value:
await self._action_notify_webhook(message, action.action_value)
except Exception:
# Le azioni non devono interrompere il flusso principale
pass
async def _action_apply_label(
self, message: Message, label_id: uuid.UUID
) -> None:
"""Applica un'etichetta al messaggio (se non gia' presente)."""
# Verifica che la label appartenga al tenant
label = await self.db.execute(
select(Label).where(
Label.id == label_id,
Label.tenant_id == message.tenant_id,
)
)
if not label.scalar_one_or_none():
return
# Verifica che non sia gia' applicata
existing = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id == message.id,
MessageLabel.label_id == label_id,
)
)
if not existing.scalar_one_or_none():
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
async def _action_notify_webhook(self, message: Message, url: str) -> None:
"""Invia una notifica webhook per il messaggio."""
import aiohttp
import json
payload = {
"event": "routing_rule_match",
"message_id": str(message.id),
"subject": message.subject,
"from_address": message.from_address,
"pec_type": message.pec_type,
}
try:
async with aiohttp.ClientSession() as session:
await session.post(
url,
json=payload,
timeout=aiohttp.ClientTimeout(total=5),
)
except Exception:
pass