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
+75 -18
View File
@@ -1,8 +1,9 @@
"""
Router Audit Log consultazione degli eventi di sistema.
Router Audit Log consultazione ed esportazione degli eventi di sistema.
Endpoint:
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
GET /api/v1/audit-log/export esportazione CSV o PDF (solo admin/super_admin)
Permessi:
- admin: vede solo gli eventi del proprio tenant
@@ -14,6 +15,7 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query
from fastapi.responses import Response, StreamingResponse
from app.dependencies import AdminUser, DB
from app.schemas.audit_log import AuditLogListResponse
@@ -22,6 +24,13 @@ from app.services.audit_service import AuditService
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
def _effective_tenant_id(current_user, tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
"""Determina il tenant_id effettivo in base al ruolo dell'utente."""
if current_user.is_super_admin:
return tenant_id # None = tutti i tenant
return current_user.tenant_id # sempre vincolato al proprio tenant
@router.get("", response_model=AuditLogListResponse)
async def list_audit_log(
current_user: AdminUser,
@@ -36,24 +45,10 @@ async def list_audit_log(
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
) -> AuditLogListResponse:
"""
Restituisce la lista paginata degli eventi di audit.
- Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
- Super Admin: vede tutti i tenant, filtrabile per tenant_id.
"""
"""Restituisce la lista paginata degli eventi di audit."""
svc = AuditService(db)
# Determina il tenant_id effettivo da applicare al filtro
if current_user.is_super_admin:
# Super admin: usa il tenant_id passato come filtro (None = tutti)
effective_tenant_id = tenant_id
else:
# Admin normale: sempre vincolato al proprio tenant
effective_tenant_id = current_user.tenant_id
return await svc.list(
tenant_id=effective_tenant_id,
tenant_id=_effective_tenant_id(current_user, tenant_id),
page=page,
page_size=page_size,
action=action,
@@ -63,3 +58,65 @@ async def list_audit_log(
date_to=date_to,
resource_type=resource_type,
)
@router.get("/export")
async def export_audit_log(
current_user: AdminUser,
db: DB,
format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
action: Optional[str] = Query(None),
user_id: Optional[uuid.UUID] = Query(None),
outcome: Optional[str] = Query(None, pattern="^(success|failure)$"),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
resource_type: Optional[str] = Query(None),
tenant_id: Optional[uuid.UUID] = Query(None),
) -> Response:
"""
Esporta i log di audit in formato CSV o PDF.
Applica gli stessi filtri dell'endpoint lista.
"""
svc = AuditService(db)
effective_tid = _effective_tenant_id(current_user, tenant_id)
# Nome file con periodo
suffix = ""
if date_from:
suffix += f"_dal_{date_from.strftime('%Y%m%d')}"
if date_to:
suffix += f"_al_{date_to.strftime('%Y%m%d')}"
if format == "csv":
csv_content = await svc.export_csv(
tenant_id=effective_tid,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
filename = f"audit_log{suffix}.csv"
return Response(
content=csv_content.encode("utf-8-sig"), # BOM per compatibilita' Excel
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else: # pdf
pdf_bytes = await svc.export_pdf_bytes(
tenant_id=effective_tid,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
filename = f"audit_log{suffix}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+4 -3
View File
@@ -33,6 +33,7 @@ from app.schemas.auth import (
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.audit_service import get_real_ip
from app.services.auth_service import AuthService
settings = get_settings()
@@ -51,7 +52,7 @@ async def login(
body: LoginRequest,
db: DB,
) -> TokenResponse:
ip = request.client.host if request.client else None
ip = get_real_ip(request)
ua = request.headers.get("user-agent")
service = AuthService(db)
@@ -177,7 +178,7 @@ async def change_password(
tenant_id=current_user.tenant_id,
user_id=current_user.id,
outcome="failure",
ip_address=request.client.host if request.client else None,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={"reason": "wrong_current_password"},
)
@@ -189,6 +190,6 @@ async def change_password(
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
ip_address=request.client.host if request.client else None,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
)
+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",
+65 -1
View File
@@ -13,11 +13,12 @@ import json
import uuid
from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile, status
from app.core.exceptions import ForbiddenError
from app.dependencies import CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
from app.services.audit_service import get_real_ip
from app.services.permission_service import PermissionService
from app.services.send_service import SendService
@@ -47,12 +48,33 @@ def _job_response(job) -> SendJobResponse:
),
)
async def create_send_job(
request: Request,
data: SendPecRequest,
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
from app.services.audit_service import log_audit
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await log_audit(
db,
"pec.sent",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"mailbox_id": str(job.mailbox_id),
"to_addresses": data.to_addresses,
"subject": data.subject,
"has_attachments": False,
},
)
await db.commit()
await db.refresh(job)
return _job_response(job)
@@ -74,6 +96,7 @@ async def create_send_job(
),
)
async def create_send_job_multipart(
request: Request,
current_user: CurrentUser,
db: DB,
data: str = Form(
@@ -85,6 +108,8 @@ async def create_send_job_multipart(
description="File allegati (0 o più, max 20 MB ciascuno)",
),
) -> SendJobResponse:
from app.services.audit_service import log_audit
# ── Parse del JSON ────────────────────────────────────────────────────────
try:
raw = json.loads(data)
@@ -128,6 +153,25 @@ async def create_send_job_multipart(
data=pec_data,
attachments=files_data if files_data else None,
)
await log_audit(
db,
"pec.sent",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"mailbox_id": str(job.mailbox_id),
"to_addresses": pec_data.to_addresses,
"subject": pec_data.subject,
"has_attachments": bool(files_data),
"attachment_count": len(files_data),
},
)
await db.commit()
await db.refresh(job)
return _job_response(job)
@@ -205,10 +249,13 @@ async def get_send_job(
summary="Annulla job di invio",
)
async def cancel_send_job(
request: Request,
job_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
from app.services.audit_service import log_audit
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
@@ -218,4 +265,21 @@ async def cancel_send_job(
raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
await svc.cancel_send_job(job_id, current_user.tenant_id)
await log_audit(
db,
"pec.send_cancelled",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job_id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"job_id": str(job_id),
"mailbox_id": str(job.mailbox_id),
"previous_status": job.status,
},
)
await db.commit()
+174
View File
@@ -0,0 +1,174 @@
"""
Router firme automatiche.
Endpoint:
GET /signatures lista firme del tenant
POST /signatures crea firma (admin)
GET /signatures/{id} dettaglio firma
PUT /signatures/{id} aggiorna firma (admin)
DELETE /signatures/{id} elimina firma (admin)
GET /signatures/assignments lista assegnazioni (con filtri opzionali)
POST /signatures/assignments crea/sostituisce assegnazione (admin)
DELETE /signatures/assignments/{id} rimuovi assegnazione (admin)
"""
import uuid
from fastapi import APIRouter, Query, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.signature import (
SignatureAssignmentCreate,
SignatureAssignmentListResponse,
SignatureAssignmentResponse,
SignatureCreate,
SignatureListResponse,
SignatureResponse,
SignatureUpdate,
)
from app.services.signature_service import SignatureService
router = APIRouter(tags=["Signatures"])
# ─── Firme ────────────────────────────────────────────────────────────────────
@router.get("/signatures", response_model=SignatureListResponse)
async def list_signatures(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Filtro per nome"),
) -> SignatureListResponse:
"""Elenca le firme del tenant corrente."""
svc = SignatureService(db)
items, total = await svc.list_signatures(current_user.tenant_id, q=q)
return SignatureListResponse(
items=[SignatureResponse.model_validate(s) for s in items],
total=total,
)
@router.post("/signatures", response_model=SignatureResponse, status_code=status.HTTP_201_CREATED)
async def create_signature(
data: SignatureCreate,
current_user: AdminUser,
db: DB,
) -> SignatureResponse:
"""Crea una nuova firma (solo admin)."""
svc = SignatureService(db)
sig = await svc.create_signature(current_user.tenant_id, data, created_by=current_user.id)
return SignatureResponse.model_validate(sig)
@router.get("/signatures/assignments", response_model=SignatureAssignmentListResponse)
async def list_assignments(
current_user: AdminUser,
db: DB,
mailbox_id: uuid.UUID | None = Query(None),
virtual_box_id: uuid.UUID | None = Query(None),
) -> SignatureAssignmentListResponse:
"""Elenca le assegnazioni firma del tenant, con filtri opzionali."""
svc = SignatureService(db)
items, total = await svc.list_assignments(
current_user.tenant_id,
mailbox_id=mailbox_id,
virtual_box_id=virtual_box_id,
)
return SignatureAssignmentListResponse(
items=[SignatureAssignmentResponse.model_validate(i) for i in items],
total=total,
)
@router.post(
"/signatures/assignments",
response_model=SignatureAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_assignment(
data: SignatureAssignmentCreate,
current_user: AdminUser,
db: DB,
) -> SignatureAssignmentResponse:
"""
Crea (o sostituisce) l'assegnazione firma per una casella/vbox+contesto.
Se esiste gia' un'assegnazione per la stessa coppia, viene sovrascritta.
"""
svc = SignatureService(db)
result = await svc.create_assignment(current_user.tenant_id, data)
return SignatureAssignmentResponse.model_validate(result)
@router.delete(
"/signatures/assignments/{assignment_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_assignment(
assignment_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Rimuove un'assegnazione firma (solo admin)."""
svc = SignatureService(db)
await svc.delete_assignment(current_user.tenant_id, assignment_id)
@router.get("/signatures/resolve", response_model=SignatureResponse | None)
async def resolve_signature(
current_user: CurrentUser,
db: DB,
context: str = Query("compose", description="Contesto: reply | compose"),
mailbox_id: uuid.UUID | None = Query(None),
virtual_box_id: uuid.UUID | None = Query(None),
) -> SignatureResponse | None:
"""
Restituisce la firma assegnata per una casella o virtual box nel contesto dato.
Usato dal ComposeModal per caricare automaticamente la firma.
"""
svc = SignatureService(db)
sig = await svc.resolve_signature(
current_user.tenant_id,
context=context,
mailbox_id=mailbox_id,
virtual_box_id=virtual_box_id,
)
if sig is None:
return None
return SignatureResponse.model_validate(sig)
@router.get("/signatures/{signature_id}", response_model=SignatureResponse)
async def get_signature(
signature_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> SignatureResponse:
"""Restituisce il dettaglio di una firma."""
svc = SignatureService(db)
sig = await svc.get_signature(current_user.tenant_id, signature_id)
return SignatureResponse.model_validate(sig)
@router.put("/signatures/{signature_id}", response_model=SignatureResponse)
async def update_signature(
signature_id: uuid.UUID,
data: SignatureUpdate,
current_user: AdminUser,
db: DB,
) -> SignatureResponse:
"""Aggiorna una firma esistente (solo admin)."""
svc = SignatureService(db)
sig = await svc.update_signature(current_user.tenant_id, signature_id, data)
return SignatureResponse.model_validate(sig)
@router.delete("/signatures/{signature_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_signature(
signature_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina una firma (solo admin)."""
svc = SignatureService(db)
await svc.delete_signature(current_user.tenant_id, signature_id)
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -102,6 +102,7 @@ app.include_router(templates.router, prefix=API_PREFIX)
app.include_router(routing_rules.router, prefix=API_PREFIX)
app.include_router(contacts.router, prefix=API_PREFIX)
app.include_router(deadlines.router, prefix=API_PREFIX)
app.include_router(signatures.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -13,3 +13,4 @@ from app.models.tenant_settings import TenantSettings # noqa: F401
from app.models.template import MessageTemplate # noqa: F401
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
from app.models.pec_contact import PecContact # noqa: F401
from app.models.signature import Signature, SignatureAssignment # noqa: F401
+118
View File
@@ -0,0 +1,118 @@
"""
Modelli Signature firme automatiche per caselle PEC e Virtual Box.
Struttura:
Signature → definisce il testo della firma (con editor rich text)
SignatureAssignment → collega una firma a una casella o virtual box per un contesto
(risposta, nuova composizione, o entrambi)
"""
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Signature(Base):
"""Firma riutilizzabile di un tenant."""
__tablename__ = "signatures"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_signature_name_tenant"),
Index("idx_signatures_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<Signature {self.name!r}>"
class SignatureAssignment(Base):
"""
Assegna una firma a una casella PEC o a una Virtual Box per un determinato contesto.
context:
reply firma inserita automaticamente nelle risposte
compose firma inserita automaticamente nelle nuove composizioni
both firma inserita in entrambi i contesti
Vincolo: esattamente uno tra mailbox_id e virtual_box_id deve essere valorizzato.
Vincolo unique: non puo' esistere piu' di un'assegnazione per la stessa
(casella/vbox, contesto) coppia.
"""
__tablename__ = "signature_assignments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
signature_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("signatures.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=True
)
virtual_box_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("virtual_boxes.id", ondelete="CASCADE"), nullable=True
)
# "reply" | "compose" | "both"
context: Mapped[str] = mapped_column(String(20), nullable=False, default="both")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
# Almeno uno tra mailbox_id e virtual_box_id deve essere valorizzato
CheckConstraint(
"(mailbox_id IS NOT NULL)::int + (virtual_box_id IS NOT NULL)::int = 1",
name="ck_sig_assignment_target",
),
# Non puo' esserci piu' di un'assegnazione per la stessa casella+contesto
UniqueConstraint("mailbox_id", "context", name="uq_sig_mailbox_context"),
# Non puo' esserci piu' di un'assegnazione per la stessa vbox+contesto
UniqueConstraint("virtual_box_id", "context", name="uq_sig_vbox_context"),
Index("idx_sig_assign_tenant", "tenant_id"),
Index("idx_sig_assign_mailbox", "mailbox_id"),
Index("idx_sig_assign_vbox", "virtual_box_id"),
)
def __repr__(self) -> str:
target = f"mailbox={self.mailbox_id}" if self.mailbox_id else f"vbox={self.virtual_box_id}"
return f"<SignatureAssignment sig={self.signature_id} {target} ctx={self.context!r}>"
+3
View File
@@ -17,6 +17,9 @@ class AuditLogResponse(BaseModel):
id: int
tenant_id: Optional[uuid.UUID] = None
user_id: Optional[uuid.UUID] = None
# Dati utente denormalizzati (arricchiti dalla JOIN nel service)
user_email: Optional[str] = None
user_full_name: Optional[str] = None
action: str
resource_type: Optional[str] = None
resource_id: Optional[uuid.UUID] = None
+23
View File
@@ -11,6 +11,27 @@ from pydantic import BaseModel, model_validator
from app.schemas.label import LabelResponse
# ─── Search match info ────────────────────────────────────────────────────────
class AttachmentMatchInfo(BaseModel):
"""Allegato in cui e' stato trovato il termine cercato."""
id: uuid.UUID
filename: str
model_config = {"from_attributes": True}
class SearchMatchInfo(BaseModel):
"""Indica dove e' stato trovato il termine di ricerca in un messaggio."""
in_subject: bool = False
in_body: bool = False
in_attachments: list[AttachmentMatchInfo] = []
model_config = {"from_attributes": True}
# ─── Attachment ───────────────────────────────────────────────────────────────
class AttachmentResponse(BaseModel):
id: uuid.UUID
message_id: uuid.UUID
@@ -59,6 +80,8 @@ class MessageResponse(BaseModel):
created_at: datetime
updated_at: datetime
labels: list[LabelResponse] = []
# Popolato solo nelle risposte di ricerca full-text
search_match: Optional[SearchMatchInfo] = None
@model_validator(mode="before")
@classmethod
+75
View File
@@ -0,0 +1,75 @@
"""
Schemi Pydantic per la gestione delle firme automatiche.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ─── Signature ────────────────────────────────────────────────────────────────
class SignatureCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = Field(None)
body_html: str | None = Field(None)
body_text: str | None = Field(None)
class SignatureUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None)
body_html: str | None = Field(None)
body_text: str | None = Field(None)
class SignatureResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None
body_html: str | None
body_text: str | None
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SignatureListResponse(BaseModel):
items: list[SignatureResponse]
total: int
# ─── SignatureAssignment ───────────────────────────────────────────────────────
SignatureContext = Literal["reply", "compose", "both"]
class SignatureAssignmentCreate(BaseModel):
signature_id: uuid.UUID
mailbox_id: uuid.UUID | None = None
virtual_box_id: uuid.UUID | None = None
context: SignatureContext = "both"
class SignatureAssignmentResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
signature_id: uuid.UUID
mailbox_id: uuid.UUID | None
virtual_box_id: uuid.UUID | None
context: str
created_at: datetime
# Nome della firma (join eagerly nel service)
signature_name: str | None = None
model_config = {"from_attributes": True}
class SignatureAssignmentListResponse(BaseModel):
items: list[SignatureAssignmentResponse]
total: int
+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()