Files
PecHub/backend/app/services/report_service.py
T
2026-03-27 13:54:07 +01:00

595 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ReportService calcola KPI, serie storiche e produce export CSV/PDF.
Non richiede migrazioni: lavora sulle tabelle messages e mailboxes esistenti.
"""
import csv
import io
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import AsyncGenerator, Optional
from sqlalchemy import case, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models.mailbox import Mailbox
from app.models.message import Message
from app.schemas.reports import (
DailyStat,
KpiSummary,
MailboxStat,
OutboundStateStat,
ReportSummaryResponse,
)
logger = get_logger(__name__)
class ReportService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── KPI principali ──────────────────────────────────────────────────────
async def get_summary(
self,
tenant_id: uuid.UUID,
period_days: int = 7,
visible_mailbox_ids: Optional[list[uuid.UUID]] = None,
) -> ReportSummaryResponse:
"""
Restituisce il riepilogo completo per la dashboard.
visible_mailbox_ids: se None l'utente e admin e vede tutto il tenant,
altrimenti filtra sulle caselle accessibili.
"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
d7_start = now - timedelta(days=7)
d30_start = now - timedelta(days=30)
# ── Filtro base tenant + caselle visibili ────────────────────────────
def _base(q):
q = q.where(Message.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
# Nessuna casella visibile: ritorna subito valori zero
return None
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
return q
async def _count(q) -> int:
q = _base(q)
if q is None:
return 0
r = await self.db.execute(q)
return r.scalar_one() or 0
# PEC ricevute
received_today = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= today_start,
)
)
received_7d = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= d7_start,
)
)
received_30d = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.received_at >= d30_start,
)
)
# PEC inviate
sent_today = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= today_start,
)
)
sent_7d = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= d7_start,
)
)
sent_30d = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= d30_start,
)
)
# Anomalie (outbound con state=anomaly, senza genitore)
anomalie = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state == "anomaly",
)
)
# Tasso consegna: delivered / (delivered + anomaly + failed)
delivered = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state == "delivered",
)
)
failed = await _count(
select(func.count(Message.id)).where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.state.in_(["anomaly", "failed"]),
)
)
total_terminal = delivered + failed
tasso_consegna = round((delivered / total_terminal * 100), 1) if total_terminal > 0 else 0.0
# Non letti
non_letti = await _count(
select(func.count(Message.id)).where(
Message.direction == "inbound",
Message.is_read == False, # noqa: E712
Message.is_trashed == False, # noqa: E712
)
)
# Totale messaggi
totale = await _count(select(func.count(Message.id)))
# Caselle in errore (NON filtrato per visible_mailbox_ids e una info admin)
caselle_errore_r = await self.db.execute(
select(func.count(Mailbox.id)).where(
Mailbox.tenant_id == tenant_id,
Mailbox.status == "error",
)
)
caselle_errore = caselle_errore_r.scalar_one() or 0
kpi = KpiSummary(
received_today=received_today,
sent_today=sent_today,
received_7d=received_7d,
sent_7d=sent_7d,
received_30d=received_30d,
sent_30d=sent_30d,
anomalie_attive=anomalie,
tasso_consegna=tasso_consegna,
caselle_in_errore=caselle_errore,
messaggi_non_letti=non_letti,
totale_messaggi=totale,
)
# ── Serie storica giornaliera ─────────────────────────────────────────
daily_stats = await self._get_daily_stats(tenant_id, period_days, visible_mailbox_ids)
# ── Distribuzione stati outbound ──────────────────────────────────────
outbound_states = await self._get_outbound_states(tenant_id, visible_mailbox_ids)
# ── Statistiche per casella ───────────────────────────────────────────
mailbox_stats = await self._get_mailbox_stats(tenant_id, visible_mailbox_ids)
return ReportSummaryResponse(
generated_at=now,
period_days=period_days,
kpi=kpi,
daily_stats=daily_stats,
outbound_states=outbound_states,
mailbox_stats=mailbox_stats,
)
# ─── Serie storica ────────────────────────────────────────────────────────
async def _get_daily_stats(
self,
tenant_id: uuid.UUID,
days: int,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[DailyStat]:
"""Conta PEC ricevute e inviate per ciascuno degli ultimi `days` giorni."""
since = datetime.now(timezone.utc) - timedelta(days=days)
def _apply_filters(q):
q = q.where(Message.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return None
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
return q
# Aggregazione ricevute per giorno
q_recv = (
select(
func.date_trunc("day", Message.received_at).label("day"),
func.count(Message.id).label("cnt"),
)
.where(
Message.direction == "inbound",
Message.received_at >= since,
)
.group_by(text("day"))
.order_by(text("day"))
)
q_recv = _apply_filters(q_recv)
# Aggregazione inviate per giorno
q_sent = (
select(
func.date_trunc("day", Message.sent_at).label("day"),
func.count(Message.id).label("cnt"),
)
.where(
Message.direction == "outbound",
Message.parent_message_id.is_(None),
Message.sent_at >= since,
)
.group_by(text("day"))
.order_by(text("day"))
)
q_sent = _apply_filters(q_sent)
recv_map: dict[date, int] = {}
sent_map: dict[date, int] = {}
if q_recv is not None:
r = await self.db.execute(q_recv)
for row in r.all():
if row.day:
d = row.day.date() if hasattr(row.day, "date") else row.day
recv_map[d] = row.cnt
if q_sent is not None:
r = await self.db.execute(q_sent)
for row in r.all():
if row.day:
d = row.day.date() if hasattr(row.day, "date") else row.day
sent_map[d] = row.cnt
# Costruisce la serie completa (tutti i giorni, anche quelli a zero)
result: list[DailyStat] = []
for i in range(days, -1, -1):
d = (datetime.now(timezone.utc) - timedelta(days=i)).date()
result.append(DailyStat(
day=d,
received=recv_map.get(d, 0),
sent=sent_map.get(d, 0),
))
return result
# ─── Distribuzione stati outbound ────────────────────────────────────────
async def _get_outbound_states(
self,
tenant_id: uuid.UUID,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[OutboundStateStat]:
q = (
select(Message.state, func.count(Message.id).label("cnt"))
.where(
Message.tenant_id == tenant_id,
Message.direction == "outbound",
Message.parent_message_id.is_(None),
)
.group_by(Message.state)
)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return []
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
r = await self.db.execute(q)
return [OutboundStateStat(state=row.state, count=row.cnt) for row in r.all()]
# ─── Statistiche per casella ──────────────────────────────────────────────
async def _get_mailbox_stats(
self,
tenant_id: uuid.UUID,
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> list[MailboxStat]:
# Carica le caselle
mb_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return []
mb_q = mb_q.where(Mailbox.id.in_(visible_mailbox_ids))
mb_result = await self.db.execute(mb_q)
mailboxes = mb_result.scalars().all()
if not mailboxes:
return []
mailbox_ids = [m.id for m in mailboxes]
mailbox_map = {m.id: m for m in mailboxes}
# Aggregazione messaggi per casella e direction
agg_q = (
select(
Message.mailbox_id,
Message.direction,
Message.state,
Message.is_read,
func.count(Message.id).label("cnt"),
)
.where(
Message.tenant_id == tenant_id,
Message.mailbox_id.in_(mailbox_ids),
Message.parent_message_id.is_(None),
)
.group_by(Message.mailbox_id, Message.direction, Message.state, Message.is_read)
)
agg_result = await self.db.execute(agg_q)
# Accumula per casella
stats: dict[uuid.UUID, MailboxStat] = {}
for mb in mailboxes:
stats[mb.id] = MailboxStat(
mailbox_id=mb.id,
email_address=mb.email_address,
display_name=mb.display_name,
status=mb.status,
last_sync_at=mb.last_sync_at,
)
for row in agg_result.all():
s = stats.get(row.mailbox_id)
if not s:
continue
if row.direction == "inbound":
s.received_total += row.cnt
if not row.is_read:
s.non_letti += row.cnt
elif row.direction == "outbound":
s.sent_total += row.cnt
if row.state == "anomaly":
s.anomalie += row.cnt
# Ordina per volume decrescente
return sorted(
stats.values(),
key=lambda x: x.received_total + x.sent_total,
reverse=True,
)
# ─── Export CSV ───────────────────────────────────────────────────────────
async def export_csv(
self,
tenant_id: uuid.UUID,
date_from: Optional[datetime],
date_to: Optional[datetime],
mailbox_id: Optional[uuid.UUID],
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> bytes:
"""Genera un CSV con tutti i messaggi del periodo."""
q = (
select(
Message.id,
Message.direction,
Message.state,
Message.pec_type,
Message.subject,
Message.from_address,
Message.received_at,
Message.sent_at,
Message.size_bytes,
Message.is_read,
Message.has_attachments,
Mailbox.email_address.label("mailbox_email"),
)
.join(Mailbox, Message.mailbox_id == Mailbox.id)
.where(
Message.tenant_id == tenant_id,
Message.parent_message_id.is_(None),
)
.order_by(Message.received_at.desc().nullslast(), Message.created_at.desc())
)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_HEADERS)
return buf.getvalue().encode("utf-8-sig")
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
if mailbox_id:
q = q.where(Message.mailbox_id == mailbox_id)
if date_from:
q = q.where(
(Message.received_at >= date_from) | (Message.sent_at >= date_from)
)
if date_to:
q = q.where(
(Message.received_at <= date_to) | (Message.sent_at <= date_to)
)
result = await self.db.execute(q)
rows = result.all()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_HEADERS)
for r in rows:
ts = r.received_at or r.sent_at
writer.writerow([
str(r.id),
r.mailbox_email or "",
r.direction or "",
r.state or "",
r.pec_type or "",
r.subject or "",
r.from_address or "",
ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "",
r.size_bytes or "",
"Si" if r.is_read else "No",
"Si" if r.has_attachments else "No",
])
return buf.getvalue().encode("utf-8-sig")
# ─── Export PDF ───────────────────────────────────────────────────────────
async def export_pdf(
self,
tenant_id: uuid.UUID,
date_from: Optional[datetime],
date_to: Optional[datetime],
visible_mailbox_ids: Optional[list[uuid.UUID]],
) -> bytes:
"""
Genera un PDF di riepilogo con KPI e tabella caselle.
Usa reportlab (puro Python, nessuna dipendenza di sistema).
"""
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import (
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
)
except ImportError:
raise RuntimeError(
"reportlab non installato. Aggiungere 'reportlab>=4.2.0' "
"alle dipendenze del backend."
)
summary = await self.get_summary(tenant_id, 30, visible_mailbox_ids)
now_str = summary.generated_at.strftime("%d/%m/%Y %H:%M")
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"Title", parent=styles["Title"], fontSize=18, spaceAfter=6,
)
subtitle_style = ParagraphStyle(
"Subtitle", parent=styles["Normal"], fontSize=10, textColor=colors.grey, spaceAfter=20,
)
heading_style = ParagraphStyle(
"Heading2", parent=styles["Heading2"], fontSize=13, spaceBefore=14, spaceAfter=6,
)
story = []
# Intestazione
story.append(Paragraph("PEChub Report Attivita PEC", title_style))
date_range = ""
if date_from:
date_range += f"Dal {date_from.strftime('%d/%m/%Y')} "
if date_to:
date_range += f"Al {date_to.strftime('%d/%m/%Y')} "
story.append(Paragraph(
f"Generato il {now_str} {date_range}",
subtitle_style,
))
# Sezione KPI
story.append(Paragraph("Indicatori Chiave (ultimi 30 giorni)", heading_style))
kpi = summary.kpi
kpi_data = [
["Indicatore", "Valore"],
["PEC ricevute oggi", str(kpi.received_today)],
["PEC inviate oggi", str(kpi.sent_today)],
["PEC ricevute (7 gg)", str(kpi.received_7d)],
["PEC inviate (7 gg)", str(kpi.sent_7d)],
["PEC ricevute (30 gg)", str(kpi.received_30d)],
["PEC inviate (30 gg)", str(kpi.sent_30d)],
["Anomalie attive", str(kpi.anomalie_attive)],
["Tasso di consegna", f"{kpi.tasso_consegna}%"],
["Caselle in errore", str(kpi.caselle_in_errore)],
["Messaggi non letti", str(kpi.messaggi_non_letti)],
]
kpi_table = Table(kpi_data, colWidths=[10 * cm, 5 * cm])
kpi_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ALIGN", (1, 0), (1, -1), "RIGHT"),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(kpi_table)
story.append(Spacer(1, 0.5 * cm))
# Sezione caselle
if summary.mailbox_stats:
story.append(Paragraph("Dettaglio per Casella", heading_style))
mb_header = ["Casella", "Stato", "Ricevute", "Inviate", "Anomalie", "Non letti"]
mb_data = [mb_header]
for ms in summary.mailbox_stats:
mb_data.append([
ms.email_address,
ms.status,
str(ms.received_total),
str(ms.sent_total),
str(ms.anomalie),
str(ms.non_letti),
])
mb_table = Table(
mb_data,
colWidths=[6.5 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm],
)
mb_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
story.append(mb_table)
doc.build(story)
return buf.getvalue()
# ─── Costanti ────────────────────────────────────────────────────────────────
CSV_HEADERS = [
"ID",
"Casella",
"Direzione",
"Stato",
"Tipo PEC",
"Oggetto",
"Mittente",
"Data/Ora",
"Dimensione (byte)",
"Letto",
"Allegati",
]