mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Implementazioni varie
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user