mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Modifiche varie
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Migrazione 0015: tabelle signatures e signature_assignments.
|
||||
Gestione firme automatiche per caselle PEC e Virtual Box.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0015"
|
||||
down_revision = "0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ─── Tabella signatures ────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"signatures",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("body_html", sa.Text, nullable=True),
|
||||
sa.Column("body_text", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="uq_signature_name_tenant"),
|
||||
)
|
||||
op.create_index("idx_signatures_tenant", "signatures", ["tenant_id"])
|
||||
|
||||
# ─── Tabella signature_assignments ─────────────────────────────────────────
|
||||
op.create_table(
|
||||
"signature_assignments",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"signature_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("signatures.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"mailbox_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("mailboxes.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"virtual_box_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("context", sa.String(20), nullable=False, server_default="both"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(mailbox_id IS NOT NULL)::int + (virtual_box_id IS NOT NULL)::int = 1",
|
||||
name="ck_sig_assignment_target",
|
||||
),
|
||||
sa.UniqueConstraint("mailbox_id", "context", name="uq_sig_mailbox_context"),
|
||||
sa.UniqueConstraint("virtual_box_id", "context", name="uq_sig_vbox_context"),
|
||||
)
|
||||
op.create_index("idx_sig_assign_tenant", "signature_assignments", ["tenant_id"])
|
||||
op.create_index("idx_sig_assign_mailbox", "signature_assignments", ["mailbox_id"])
|
||||
op.create_index("idx_sig_assign_vbox", "signature_assignments", ["virtual_box_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_sig_assign_vbox", table_name="signature_assignments")
|
||||
op.drop_index("idx_sig_assign_mailbox", table_name="signature_assignments")
|
||||
op.drop_index("idx_sig_assign_tenant", table_name="signature_assignments")
|
||||
op.drop_table("signature_assignments")
|
||||
|
||||
op.drop_index("idx_signatures_tenant", table_name="signatures")
|
||||
op.drop_table("signatures")
|
||||
@@ -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}"'},
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user