feat: async email slanje s cache PDF fallbackom i admin logom

Prebaci slanje emaila putnog naloga u pozadinski task bez blokiranja UI-a i dodaj fallback kad queue nije dostupna. Kod greške slanja spremi generirane PDF-ove u cache i pošalji notifikaciju s download linkovima.

Dodaj EmailDispatchLog model i Django admin pregled za praćenje kome je poslano, što je poslano, status i detalje privitaka.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-21 14:41:45 +02:00
parent 42fcd836ed
commit 5945423346
11 changed files with 1338 additions and 85 deletions

View File

@@ -1,15 +1,23 @@
# backend/modules/fleet/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.core.mail import EmailMessage
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
from io import StringIO
from pathlib import Path
import base64
import re
import csv
import mimetypes
from decimal import Decimal, InvalidOperation
from smtplib import SMTPSenderRefused
from datetime import timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from PIL import Image, UnidentifiedImageError
from django.db import transaction
from pypdf import PdfReader
@@ -93,6 +101,450 @@ def send_email_task(recipient_email, subject, message, notif_id):
logger.error(f"Async mail failed: {exc}")
def _guess_content_type(filename):
mime, _ = mimetypes.guess_type(str(filename or ''))
return mime or 'application/octet-stream'
def _file_attachment(file_field, fallback_name):
if not file_field:
return None
file_field.open('rb')
try:
content = file_field.read()
finally:
file_field.close()
if not content:
return None
filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name
return (filename, content, _guess_content_type(filename))
def _build_image_attachments_for_work_order(work_order):
from .models import WorkOrderPhoto, WorkOrderInvoice
attachments = []
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, photo in enumerate(photos, start=1):
attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin")
if attachment:
attachments.append(attachment)
invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin")
if attachment:
attachments.append(attachment)
return attachments
def _user_display_name(user):
if not user:
return None
full_name = user.get_full_name()
if full_name:
return full_name
if getattr(user, 'email', None):
return user.email
if getattr(user, 'username', None):
return user.username
return str(getattr(user, 'pk', ''))
def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key):
from modules.task_management.models import Task
tasks = (
Task.objects
.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
scheduled_date__year=year,
scheduled_date__month=month,
)
.select_related('assigned_to', 'work_order')
.order_by('scheduled_date', 'created_at')
)
csv_buffer = StringIO()
writer = csv.writer(csv_buffer)
writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis'])
for task in tasks:
assignee_name = _user_display_name(task.assigned_to) or '-'
work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-'
writer.writerow([
task.scheduled_date.isoformat() if task.scheduled_date else '',
task.title or '',
task.status or '',
assignee_name,
work_order_label,
task.description or '',
])
csv_content = csv_buffer.getvalue().encode('utf-8')
filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv"
return (filename, csv_content, 'text/csv'), tasks.count()
def _resolve_from_email():
candidates = [
getattr(settings, 'DEFAULT_FROM_EMAIL', None),
getattr(settings, 'EMAIL_HOST_USER', None),
]
for candidate in candidates:
if not candidate:
continue
normalized = str(candidate).strip()
if not normalized:
continue
try:
validate_email(normalized)
return normalized
except DjangoValidationError:
continue
return None
def _detect_generated_pdf_type(filename):
normalized = str(filename or '').strip().lower()
if normalized.endswith('.work-order-invoices.pdf'):
return 'invoices'
if normalized.endswith('.work-order-service-records.pdf'):
return 'service_records'
if normalized.endswith('.work-order.pdf'):
return 'work_order'
return None
def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments):
cached_payload = []
ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24))
expires_at = timezone.now() + timedelta(hours=ttl_hours)
for filename, content, content_type in attachments:
if str(content_type or '').lower() != 'application/pdf':
continue
pdf_type = _detect_generated_pdf_type(filename)
if not pdf_type:
continue
generated = GeneratedWorkOrderPdf.objects.create(
work_order=work_order,
requested_by=requested_by,
pdf_type=pdf_type,
status='ready',
filename=filename,
expires_at=expires_at,
)
generated.file.save(filename, ContentFile(content), save=False)
generated.save(update_fields=['file', 'updated_at'])
cached_payload.append(
{
'generated_pdf_id': str(generated.pk),
'pdf_type': pdf_type,
'filename': filename,
'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
'expires_at': expires_at.isoformat(),
}
)
return cached_payload
@shared_task
def send_work_order_email_bundle_task(
*,
work_order_id,
requested_by_id,
recipients,
subject='',
message='',
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
month_year=None,
month_number=None,
month_key='',
service_note_id=None,
dispatch_log_id=None,
):
from django.contrib.auth import get_user_model
from .models import WorkOrder, ServiceContextNote, EmailDispatchLog
from .services import NotificationService
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
User = get_user_model()
requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first()
dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None
work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first()
if work_order is None:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Putni nalog nije pronađen.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Putni nalog za slanje emaila nije pronađen.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'work-order-not-found'}
body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')]
if service_note_id:
note = ServiceContextNote.objects.select_related('created_by').filter(
pk=service_note_id,
is_active=True,
).first()
if note:
creator_name = _user_display_name(note.created_by) or '-'
note_date = note.note_date.isoformat() if note.note_date else '-'
body_lines.append("")
body_lines.append("Odabrana bilješka servisnog konteksta:")
body_lines.append(f"- Autor: {creator_name}")
body_lines.append(f"- Datum bilješke: {note_date}")
body_lines.append(f"- Tekst: {note.note}")
attachments = []
if include_work_order_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order.pdf",
_build_work_order_pdf(work_order),
'application/pdf',
))
if include_service_records_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-service-records.pdf",
_build_work_order_service_records_pdf(work_order),
'application/pdf',
))
if include_invoices_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-invoices.pdf",
_build_work_order_invoices_pdf(work_order),
'application/pdf',
))
if include_images:
image_attachments = _build_image_attachments_for_work_order(work_order)
attachments.extend(image_attachments)
body_lines.append("")
body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.")
monthly_tasks_count = 0
if include_monthly_tasks and month_year and month_number and month_key:
monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment(
work_order,
year=month_year,
month=month_number,
month_key=month_key,
)
attachments.append(monthly_attachment)
body_lines.append("")
body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).")
cached_pdf_payload = []
if attachments:
cached_pdf_payload = _cache_pdf_attachments_for_download(
work_order=work_order,
requested_by=requested_by,
attachments=attachments,
)
from_email = _resolve_from_email()
if not from_email:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'invalid-from-email'}
total_attachment_bytes = sum(len(content) for _, content, _ in attachments)
max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024))
if total_attachment_bytes > max_attachment_bytes:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email nije poslan — preveliki privitci',
message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'attachments-too-large'}
if not attachments:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Nije odabran nijedan privitak.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Nije odabran nijedan PDF/prilog za slanje.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'no-attachments'}
email_message = EmailMessage(
subject=subject or f"Putni nalog {_work_order_display_code(work_order)}",
body="\n".join(body_lines).strip(),
from_email=from_email,
to=recipients or [],
)
for filename, content, content_type in attachments:
email_message.attach(filename, content, content_type)
if dispatch_log:
dispatch_log.attachments = [
{
'filename': filename,
'content_type': content_type,
'size_bytes': len(content),
}
for filename, content, content_type in attachments
]
dispatch_log.save(update_fields=['attachments', 'updated_at'])
try:
email_message.send(fail_silently=False)
except SMTPSenderRefused as exc:
logger.exception("SMTP sender refused for work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'smtp-sender-refused'}
except Exception as exc:
logger.exception("Greška kod slanja work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = str(exc)
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': str(exc)}
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email uspješno poslan',
message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.',
level='success',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'completed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
},
)
if dispatch_log:
dispatch_log.status = 'sent'
dispatch_log.error_message = ''
dispatch_log.sent_at = timezone.now()
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
}
dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at'])
return {
'status': 'ok',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
}
def _build_work_order_invoices_pdf(work_order):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)