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
+159 -2
View File
@@ -23,12 +23,13 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Query, status
from fastapi import APIRouter, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.services.audit_service import get_real_ip
from app.services.search_service import SearchService
from app.config import get_settings
@@ -38,12 +39,14 @@ from app.dependencies import CurrentUser, DB
from app.models.label import Label
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentMatchInfo,
AttachmentResponse,
MessageBulkUpdateRequest,
MessageBulkUpdateResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
SearchMatchInfo,
)
router = APIRouter(prefix="/messages", tags=["Messages"])
@@ -330,8 +333,45 @@ async def list_messages(
result = await db.execute(q)
items = list(result.scalars().all())
# ── Popola search_match per i risultati di ricerca ────────────────────────
if search and items:
term_lower = search.lower()
msg_ids = [m.id for m in items]
term_like = f"%{search}%"
# Query batch: allegati con extracted_text che matcha il termine
att_result = await db.execute(
select(Attachment.id, Attachment.message_id, Attachment.filename)
.where(
Attachment.message_id.in_(msg_ids),
Attachment.extracted_text.ilike(term_like),
)
)
# Mappa message_id → lista di AttachmentMatchInfo che matchano
att_matches: dict[uuid.UUID, list[AttachmentMatchInfo]] = {}
for row in att_result.fetchall():
att_id, msg_id, filename = row
att_matches.setdefault(msg_id, []).append(
AttachmentMatchInfo(id=att_id, filename=filename)
)
message_responses: list[MessageResponse] = []
for m in items:
resp = MessageResponse.model_validate(m)
in_subject = bool(m.subject and term_lower in m.subject.lower())
in_body = bool(m.body_text and term_lower in m.body_text.lower())
in_attachments = att_matches.get(m.id, [])
resp.search_match = SearchMatchInfo(
in_subject=in_subject,
in_body=in_body,
in_attachments=in_attachments,
)
message_responses.append(resp)
else:
message_responses = [MessageResponse.model_validate(m) for m in items]
return MessageListResponse(
items=[MessageResponse.model_validate(m) for m in items],
items=message_responses,
total=total,
page=page,
page_size=page_size,
@@ -340,6 +380,7 @@ async def list_messages(
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages(
request: Request,
data: MessageBulkUpdateRequest,
current_user: CurrentUser,
db: DB,
@@ -349,6 +390,8 @@ async def bulk_update_messages(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
from app.services.audit_service import log_audit
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
@@ -415,6 +458,29 @@ async def bulk_update_messages(
elif not data.is_conserved:
message.conserved_at = None
# Registra evento audit bulk
if messages:
changes: dict = {}
for field in ("is_read", "is_starred", "is_archived", "is_trashed",
"is_pending_conservation", "is_conserved"):
v = getattr(data, field, None)
if v is not None:
changes[field] = v
await log_audit(
db,
"message.bulk_updated",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"count": len(messages),
"message_ids": [str(m.id) for m in messages],
"changes": changes,
},
)
await db.commit()
if messages:
@@ -434,17 +500,37 @@ async def bulk_update_messages(
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
request: Request,
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> MessageResponse:
"""Carica un messaggio per ID."""
from app.services.audit_service import log_audit
message = await _resolve_message(message_id, current_user, db)
await log_audit(
db,
"message.opened",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"subject": message.subject,
"from_address": message.from_address,
"direction": message.direction,
"mailbox_id": str(message.mailbox_id),
},
)
await db.commit()
return MessageResponse.model_validate(message)
@router.patch("/{message_id}", response_model=MessageResponse)
async def update_message(
request: Request,
message_id: uuid.UUID,
data: MessageUpdateRequest,
current_user: CurrentUser,
@@ -455,6 +541,8 @@ async def update_message(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
from app.services.audit_service import log_audit
message = await _resolve_message(message_id, current_user, db)
# Verifica permesso conservazione se necessario
@@ -464,6 +552,19 @@ async def update_message(
await perm_svc.require_can_conserve(current_user, message.mailbox_id)
now = datetime.now(timezone.utc)
ip = get_real_ip(request)
ua = request.headers.get("user-agent")
base_payload = {"subject": message.subject, "mailbox_id": str(message.mailbox_id)}
# Mappa flag → coppia (action_true, action_false)
_FLAG_ACTIONS: dict[str, tuple[str, str]] = {
"is_read": ("message.read", "message.unread"),
"is_starred": ("message.starred", "message.unstarred"),
"is_archived": ("message.archived", "message.unarchived"),
"is_trashed": ("message.trashed", "message.restored"),
"is_pending_conservation": ("message.pending_conservation","message.conservation_cancelled"),
"is_conserved": ("message.conserved", "message.conservation_removed"),
}
if data.is_read is not None:
message.is_read = data.is_read
@@ -494,6 +595,23 @@ async def update_message(
elif not data.is_conserved:
message.conserved_at = None
# Registra un evento di audit per ogni flag modificato
for field, (action_true, action_false) in _FLAG_ACTIONS.items():
value = getattr(data, field, None)
if value is not None:
action = action_true if value else action_false
await log_audit(
db,
action,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message_id,
ip_address=ip,
user_agent=ua,
payload=base_payload,
)
await db.commit()
refreshed = await db.execute(
select(Message)
@@ -524,12 +642,15 @@ async def list_attachments(
@router.get("/{message_id}/attachments/{attachment_id}/download")
async def download_attachment(
request: Request,
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> StreamingResponse:
"""Scarica un allegato direttamente da MinIO."""
from app.services.audit_service import log_audit
await _resolve_message(message_id, current_user, db)
result = await db.execute(
@@ -542,6 +663,23 @@ async def download_attachment(
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
await log_audit(
db,
"message.attachment_downloaded",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="attachment",
resource_id=attachment.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"filename": attachment.filename,
"message_id": str(message_id),
"size_bytes": attachment.size_bytes,
},
)
await db.commit()
try:
from miniopy_async import Minio
@@ -580,6 +718,7 @@ async def download_attachment(
@router.get("/{message_id}/download-package")
async def download_package(
request: Request,
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
@@ -588,6 +727,7 @@ async def download_package(
import io
import zipfile as _zipfile
from app.services.audit_service import log_audit
from miniopy_async import Minio
message = await _resolve_message(message_id, current_user, db)
@@ -682,6 +822,23 @@ async def download_package(
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
zip_filename = f"pec_{safe_subject}.zip"
await log_audit(
db,
"message.package_downloaded",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"subject": message.subject,
"zip_filename": zip_filename,
"zip_size_bytes": len(zip_bytes),
},
)
await db.commit()
return StreamingResponse(
iter([zip_bytes]),
media_type="application/zip",