Modifiche varie

This commit is contained in:
2026-06-04 20:54:49 +02:00
parent ccc4167e28
commit e31676d22e
31 changed files with 3058 additions and 153 deletions
+246 -22
View File
@@ -12,25 +12,54 @@ Uso tipico nei router/servizi:
resource_type="user",
resource_id=new_user.id,
outcome="success",
ip_address=request.client.host if request.client else None,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={"email": new_user.email},
)
"""
import csv
import io
import math
import uuid
from datetime import datetime
from typing import Optional
from fastapi import Request
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.core.pagination import PaginatedResponse, PaginationParams
from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit_log import AuditLogResponse
# ─── Helper IP reale (legge X-Real-IP o X-Forwarded-For da reverse proxy) ────
def get_real_ip(request: Request) -> Optional[str]:
"""
Restituisce l'IP reale del client, leggendo gli header del reverse proxy.
Priorita': X-Real-IP > primo IP di X-Forwarded-For > request.client.host
"""
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
x_forwarded_for = request.headers.get("x-forwarded-for", "")
if x_forwarded_for:
# Prende il primo IP della catena (quello del client originale)
first_ip = x_forwarded_for.split(",")[0].strip()
if first_ip:
return first_ip
if request.client:
return request.client.host
return None
# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
async def log_audit(
@@ -79,31 +108,35 @@ class AuditService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list(
def _build_query(
self,
*,
tenant_id: Optional[uuid.UUID],
page: int = 1,
page_size: int = 25,
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
) -> PaginatedResponse[AuditLogResponse]:
"""
Restituisce la lista paginata degli eventi audit.
):
"""Costruisce la query base con JOIN utente e filtri."""
UserAlias = aliased(User)
stmt = (
select(
AuditLog,
UserAlias.email.label("user_email"),
UserAlias.full_name.label("user_full_name"),
)
.outerjoin(UserAlias, UserAlias.id == AuditLog.user_id)
)
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
"""
filters = []
if tenant_id is not None:
filters.append(AuditLog.tenant_id == tenant_id)
if action:
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
if action.endswith("*"):
filters.append(AuditLog.action.like(action[:-1] + "%"))
else:
@@ -124,30 +157,221 @@ class AuditService:
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
if filters:
stmt = stmt.where(and_(*filters))
return stmt
async def list(
self,
*,
tenant_id: Optional[uuid.UUID],
page: int = 1,
page_size: int = 25,
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
) -> PaginatedResponse[AuditLogResponse]:
"""
Restituisce la lista paginata degli eventi audit con dati utente.
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
"""
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
# Count totale
count_q = select(func.count()).select_from(AuditLog).where(where_clause)
count_q = select(func.count()).select_from(base_stmt.subquery())
total = (await self.db.execute(count_q)).scalar_one()
# Dati paginati
offset = (page - 1) * page_size
items_q = (
select(AuditLog)
.where(where_clause)
.order_by(AuditLog.occurred_at.desc())
.offset(offset)
.limit(page_size)
)
result = await self.db.execute(items_q)
items = list(result.scalars().all())
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).offset(offset).limit(page_size)
rows = (await self.db.execute(items_q)).all()
items = []
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
resp = AuditLogResponse.model_validate(entry)
resp.user_email = email
resp.user_full_name = full_name
items.append(resp)
pages = math.ceil(total / page_size) if page_size > 0 else 0
return PaginatedResponse[AuditLogResponse](
items=[AuditLogResponse.model_validate(item) for item in items],
items=items,
total=total,
page=page,
page_size=page_size,
pages=pages,
)
async def export_csv(
self,
*,
tenant_id: Optional[uuid.UUID],
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
limit: int = 10000,
) -> str:
"""
Restituisce i log in formato CSV (stringa).
Massimo `limit` righe per prevenire export eccessivi.
"""
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
rows = (await self.db.execute(items_q)).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"ID", "Data/Ora", "Azione", "Esito",
"Utente", "Email utente", "IP",
"Tipo risorsa", "ID risorsa",
])
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
writer.writerow([
entry.id,
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
entry.action,
entry.outcome,
full_name or "",
email or "",
str(entry.ip_address) if entry.ip_address else "",
entry.resource_type or "",
str(entry.resource_id) if entry.resource_id else "",
])
return output.getvalue()
async def export_pdf_bytes(
self,
*,
tenant_id: Optional[uuid.UUID],
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
limit: int = 5000,
) -> bytes:
"""
Genera un PDF con i log di audit usando reportlab.
Restituisce i byte del PDF.
"""
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer,
)
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
rows = (await self.db.execute(items_q)).all()
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=landscape(A4),
rightMargin=1 * cm,
leftMargin=1 * cm,
topMargin=1.5 * cm,
bottomMargin=1.5 * cm,
)
styles = getSampleStyleSheet()
story = []
# Titolo
title_text = "Audit Log"
if date_from or date_to:
parts = []
if date_from:
parts.append(f"dal {date_from.strftime('%d/%m/%Y')}")
if date_to:
parts.append(f"al {date_to.strftime('%d/%m/%Y')}")
title_text += " " + " ".join(parts)
story.append(Paragraph(title_text, styles["Heading1"]))
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(
f"Esportato il {datetime.now().strftime('%d/%m/%Y %H:%M')}{len(rows)} record",
styles["Normal"],
))
story.append(Spacer(1, 0.5 * cm))
# Tabella
headers = ["Data/Ora", "Azione", "Esito", "Utente", "IP", "Risorsa"]
table_data = [headers]
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
utente = full_name or email or (str(entry.user_id)[:8] if entry.user_id else "")
risorsa = entry.resource_type or ""
table_data.append([
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
entry.action,
"OK" if entry.outcome == "success" else "FAIL",
utente,
str(entry.ip_address) if entry.ip_address else "",
risorsa,
])
col_widths = [3.8 * cm, 5.0 * cm, 1.8 * cm, 4.5 * cm, 3.5 * cm, 3.0 * cm]
table = Table(table_data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a5f")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTSIZE", (0, 1), (-1, -1), 7),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f8ff")]),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cccccc")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(table)
doc.build(story)
return buf.getvalue()
+258
View File
@@ -0,0 +1,258 @@
"""
Service layer per la gestione delle firme automatiche.
"""
import uuid
from typing import Sequence
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import NotFoundError, ValidationError
from app.models.signature import Signature, SignatureAssignment
from app.schemas.signature import SignatureCreate, SignatureUpdate, SignatureAssignmentCreate
class SignatureService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Firme ────────────────────────────────────────────────────────────────
async def list_signatures(
self, tenant_id: uuid.UUID, q: str | None = None
) -> tuple[Sequence[Signature], int]:
stmt = select(Signature).where(Signature.tenant_id == tenant_id)
if q:
stmt = stmt.where(Signature.name.ilike(f"%{q}%"))
stmt = stmt.order_by(Signature.name)
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await self.db.execute(count_stmt)).scalar_one()
items = (await self.db.execute(stmt)).scalars().all()
return items, total
async def get_signature(
self, tenant_id: uuid.UUID, signature_id: uuid.UUID
) -> Signature:
stmt = select(Signature).where(
Signature.id == signature_id,
Signature.tenant_id == tenant_id,
)
sig = (await self.db.execute(stmt)).scalar_one_or_none()
if sig is None:
raise NotFoundError("Firma non trovata")
return sig
async def create_signature(
self,
tenant_id: uuid.UUID,
data: SignatureCreate,
created_by: uuid.UUID | None = None,
) -> Signature:
sig = Signature(
tenant_id=tenant_id,
name=data.name,
description=data.description,
body_html=data.body_html,
body_text=data.body_text,
created_by=created_by,
)
self.db.add(sig)
await self.db.commit()
await self.db.refresh(sig)
return sig
async def update_signature(
self,
tenant_id: uuid.UUID,
signature_id: uuid.UUID,
data: SignatureUpdate,
) -> Signature:
sig = await self.get_signature(tenant_id, signature_id)
if data.name is not None:
sig.name = data.name
if data.description is not None:
sig.description = data.description
if data.body_html is not None:
sig.body_html = data.body_html
if data.body_text is not None:
sig.body_text = data.body_text
await self.db.commit()
await self.db.refresh(sig)
return sig
async def delete_signature(
self, tenant_id: uuid.UUID, signature_id: uuid.UUID
) -> None:
sig = await self.get_signature(tenant_id, signature_id)
await self.db.delete(sig)
await self.db.commit()
# ─── Assegnazioni ─────────────────────────────────────────────────────────
async def list_assignments(
self,
tenant_id: uuid.UUID,
mailbox_id: uuid.UUID | None = None,
virtual_box_id: uuid.UUID | None = None,
) -> tuple[list[dict], int]:
"""
Restituisce le assegnazioni con il nome della firma incluso.
Filtro opzionale per casella o virtual box.
"""
stmt = (
select(SignatureAssignment, Signature.name.label("signature_name"))
.join(Signature, Signature.id == SignatureAssignment.signature_id)
.where(SignatureAssignment.tenant_id == tenant_id)
)
if mailbox_id:
stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id)
if virtual_box_id:
stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id)
stmt = stmt.order_by(SignatureAssignment.created_at)
rows = (await self.db.execute(stmt)).all()
items = []
for row in rows:
assignment: SignatureAssignment = row[0]
sig_name: str = row[1]
items.append({
"id": assignment.id,
"tenant_id": assignment.tenant_id,
"signature_id": assignment.signature_id,
"mailbox_id": assignment.mailbox_id,
"virtual_box_id": assignment.virtual_box_id,
"context": assignment.context,
"created_at": assignment.created_at,
"signature_name": sig_name,
})
return items, len(items)
async def create_assignment(
self,
tenant_id: uuid.UUID,
data: SignatureAssignmentCreate,
) -> dict:
# Validazione: esattamente uno tra mailbox_id e virtual_box_id
if bool(data.mailbox_id) == bool(data.virtual_box_id):
raise ValidationError(
"Specificare esattamente uno tra mailbox_id e virtual_box_id"
)
# Verifica che la firma esista nel tenant
await self.get_signature(tenant_id, data.signature_id)
# Rimuovi eventuale assegnazione precedente per la stessa coppia (target, context)
existing_stmt = select(SignatureAssignment).where(
SignatureAssignment.tenant_id == tenant_id,
SignatureAssignment.context == data.context,
)
if data.mailbox_id:
existing_stmt = existing_stmt.where(
SignatureAssignment.mailbox_id == data.mailbox_id
)
else:
existing_stmt = existing_stmt.where(
SignatureAssignment.virtual_box_id == data.virtual_box_id
)
existing = (await self.db.execute(existing_stmt)).scalar_one_or_none()
if existing:
await self.db.delete(existing)
await self.db.flush() # Flush il DELETE prima dell'INSERT per evitare UniqueViolationError
assignment = SignatureAssignment(
tenant_id=tenant_id,
signature_id=data.signature_id,
mailbox_id=data.mailbox_id,
virtual_box_id=data.virtual_box_id,
context=data.context,
)
self.db.add(assignment)
await self.db.commit()
await self.db.refresh(assignment)
# Carica il nome della firma per la risposta
sig = await self.get_signature(tenant_id, data.signature_id)
return {
"id": assignment.id,
"tenant_id": assignment.tenant_id,
"signature_id": assignment.signature_id,
"mailbox_id": assignment.mailbox_id,
"virtual_box_id": assignment.virtual_box_id,
"context": assignment.context,
"created_at": assignment.created_at,
"signature_name": sig.name,
}
async def delete_assignment(
self, tenant_id: uuid.UUID, assignment_id: uuid.UUID
) -> None:
stmt = select(SignatureAssignment).where(
SignatureAssignment.id == assignment_id,
SignatureAssignment.tenant_id == tenant_id,
)
assignment = (await self.db.execute(stmt)).scalar_one_or_none()
if assignment is None:
raise NotFoundError("Assegnazione firma non trovata")
await self.db.delete(assignment)
await self.db.commit()
async def resolve_signature(
self,
tenant_id: uuid.UUID,
context: str,
mailbox_id: uuid.UUID | None = None,
virtual_box_id: uuid.UUID | None = None,
) -> Signature | None:
"""
Restituisce la firma assegnata per casella/vbox nel contesto specificato.
Cerca prima un'assegnazione con context == context, poi context == 'both'.
"""
if not mailbox_id and not virtual_box_id:
return None
stmt = (
select(SignatureAssignment)
.where(
SignatureAssignment.tenant_id == tenant_id,
SignatureAssignment.context.in_([context, "both"]),
)
)
if mailbox_id:
stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id)
else:
stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id)
assignments = (await self.db.execute(stmt)).scalars().all()
if not assignments:
return None
# Preferisce il match esatto sul contesto rispetto a 'both'
exact = next((a for a in assignments if a.context == context), None)
assignment = exact or assignments[0]
return await self.get_signature(tenant_id, assignment.signature_id)
async def delete_assignment_by_target(
self,
tenant_id: uuid.UUID,
context: str,
mailbox_id: uuid.UUID | None = None,
virtual_box_id: uuid.UUID | None = None,
) -> None:
"""Rimuove l'assegnazione per una specifica casella/vbox+contesto (se presente)."""
stmt = select(SignatureAssignment).where(
SignatureAssignment.tenant_id == tenant_id,
SignatureAssignment.context == context,
)
if mailbox_id:
stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id)
elif virtual_box_id:
stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id)
else:
return
assignment = (await self.db.execute(stmt)).scalar_one_or_none()
if assignment:
await self.db.delete(assignment)
await self.db.commit()