Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
+281
View File
@@ -709,3 +709,284 @@ async def list_receipts(
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]
# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
@router.get("/{message_id}/thread", response_model=list[MessageResponse])
async def get_thread(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageResponse]:
"""
Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
Risale alla radice della conversazione (risalendo i parent_message_id),
poi carica tutti i messaggi del thread ordinati cronologicamente.
Esclude le ricevute PEC (pec_type != posta_certificata).
"""
message = await _resolve_message(message_id, current_user, db)
# Risale alla radice del thread
root_id = message.id
visited: set[uuid.UUID] = {message.id}
current = message
while current.parent_message_id and current.parent_message_id not in visited:
visited.add(current.parent_message_id)
parent_result = await db.execute(
select(Message).where(
Message.id == current.parent_message_id,
Message.tenant_id == current_user.tenant_id,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
break
current = parent
root_id = current.id
# Carica ricorsivamente tutti i messaggi del thread dalla radice
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
thread_messages: list[Message] = []
async def _collect(msg_id: uuid.UUID) -> None:
result = await db.execute(
select(Message)
.where(
Message.id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
)
msg = result.scalar_one_or_none()
if msg:
thread_messages.append(msg)
children_result = await db.execute(
select(Message)
.where(
Message.parent_message_id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
children = list(children_result.scalars().all())
for child in children:
await _collect(child.id)
await _collect(root_id)
# Ordina cronologicamente
thread_messages.sort(
key=lambda m: m.received_at or m.sent_at or m.created_at
)
return [MessageResponse.model_validate(m) for m in thread_messages]
# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
async def get_attachment_preview_url(
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> dict:
"""
Restituisce una presigned URL MinIO per la preview inline dell'allegato.
La URL e' valida per 5 minuti. Supporta PDF e immagini.
Per altri tipi di file reindirizza al download normale.
"""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
Attachment.message_id == message_id,
)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
content_type = attachment.content_type or "application/octet-stream"
previewable = (
content_type.startswith("image/") or
content_type == "application/pdf"
)
if not previewable:
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
try:
from datetime import timedelta as _timedelta
from miniopy_async import Minio
client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
presigned_url = await client.presigned_get_object(
settings.minio_bucket,
attachment.storage_path,
expires=_timedelta(minutes=5),
)
return {
"previewable": True,
"content_type": content_type,
"filename": attachment.filename,
"url": presigned_url,
}
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
@router.get("/{message_id}/print")
async def print_message(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> "HTMLResponse":
"""
Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
Include: intestazione, corpo, lista allegati, albero ricevute.
Pronto per window.print() o salvataggio come PDF tramite browser.
"""
from fastapi.responses import HTMLResponse
message = await _resolve_message(message_id, current_user, db)
att_result = await db.execute(
select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
)
attachments = list(att_result.scalars().all())
receipts_html = ""
if message.direction == "outbound":
rec_result = await db.execute(
select(Message)
.where(Message.parent_message_id == message.id)
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
receipts = list(rec_result.scalars().all())
PEC_TYPE_LABELS = {
"accettazione": "Accettazione",
"avvenuta_consegna": "Avvenuta consegna",
"non_accettazione": "Non accettazione",
"mancata_consegna": "Mancata consegna",
"errore_consegna": "Errore consegna",
"presa_in_carico": "Presa in carico",
"preavviso_mancata_consegna": "Preavviso mancata consegna",
"rilevazione_virus": "Rilevazione virus",
}
receipt_rows = ""
for r in receipts:
label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
date_str = ""
if r.received_at:
date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
receipt_rows += f"<tr><td>{label}</td><td>{date_str}</td></tr>"
if receipt_rows:
receipts_html = f"""
<section>
<h3>Tracciamento invio</h3>
<table>
<thead><tr><th>Tipo ricevuta</th><th>Data</th></tr></thead>
<tbody>{receipt_rows}</tbody>
</table>
</section>"""
att_rows = ""
for att in attachments:
size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
att_rows += f"<li>{att.filename} ({att.content_type or ''}) {size_str}</li>"
att_html = f"<section><h3>Allegati ({len(attachments)})</h3><ul>{att_rows}</ul></section>" if attachments else ""
from_label = "Da" if message.direction == "inbound" else "A"
from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
date_val = ""
date_field = message.received_at or message.sent_at or message.created_at
if date_field:
date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
body_html = ""
if message.body_html:
body_html = f"<div class='body'>{message.body_html}</div>"
elif message.body_text:
body_html = f"<pre class='body'>{message.body_text}</pre>"
deadline_html = ""
if message.deadline_at:
dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
deadline_html = f"<p class='deadline'><strong>Scadenza:</strong> {dl_str}</p>"
if message.deadline_note:
deadline_html += f"<p><em>Nota scadenza: {message.deadline_note}</em></p>"
html = f"""<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>PEC - {message.subject or '(nessun oggetto)'}</title>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12pt; margin: 2cm; color: #000; }}
h1 {{ font-size: 16pt; border-bottom: 2px solid #333; padding-bottom: 8px; }}
h3 {{ font-size: 13pt; margin-top: 20px; border-bottom: 1px solid #ccc; }}
.meta {{ background: #f5f5f5; border: 1px solid #ddd; padding: 12px; margin-bottom: 16px; }}
.meta p {{ margin: 4px 0; }}
.body {{ border: 1px solid #ddd; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #eee; }}
ul {{ padding-left: 20px; }}
.deadline {{ color: #c00; font-weight: bold; }}
@media print {{ body {{ margin: 1cm; }} }}
</style>
</head>
<body>
<h1>{message.subject or '(nessun oggetto)'}</h1>
<div class="meta">
<p><strong>{from_label}:</strong> {from_val}</p>
{'<p><strong>Da:</strong> ' + message.from_address + '</p>' if message.direction == "outbound" and message.from_address else ''}
{'<p><strong>A:</strong> ' + ', '.join(message.to_addresses or []) + '</p>' if message.direction == "inbound" and message.to_addresses else ''}
{'<p><strong>Cc:</strong> ' + ', '.join(message.cc_addresses or []) + '</p>' if message.cc_addresses else ''}
<p><strong>Data:</strong> {date_val}</p>
<p><strong>Stato:</strong> {message.state}</p>
<p><strong>Tipo:</strong> {message.pec_type}</p>
</div>
{deadline_html}
{body_html}
{att_html}
{receipts_html}
<p style="margin-top: 30px; font-size: 9pt; color: #888;">
Documento generato da PEChub il {date_val} ID messaggio: {message.id}
</p>
</body>
</html>"""
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")