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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user