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,7 +1,11 @@
# /backend/modules/fleet/views.py
from io import BytesIO
from io import StringIO
import base64
import csv
import mimetypes
import threading
from collections import OrderedDict
from datetime import timedelta
from pathlib import Path
@@ -11,6 +15,8 @@ from django.contrib.auth import get_user_model
from PIL import Image, UnidentifiedImageError
from django.conf import settings
from django.core.mail import EmailMessage
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.http import HttpResponse, FileResponse
from django.urls import reverse
from django.utils import timezone
@@ -35,6 +41,7 @@ from .models import (
Crane,
Vehicle,
VehicleNotification,
EmailDispatchLog,
ServiceContextNote,
WorkOrder,
WorkOrderPhoto,
@@ -70,6 +77,7 @@ from .tasks import (
build_work_order_pdf_cached_task,
cleanup_expired_generated_pdfs_task,
process_work_order_invoice_ocr,
send_work_order_email_bundle_task,
)
register_unicode_fonts()
@@ -1458,6 +1466,207 @@ def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
message.send(fail_silently=False)
def _parse_boolean_flag(value, *, field_name, default=False):
if value is None:
return default
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {'1', 'true', 'yes', 'da', 'on'}:
return True
if normalized in {'0', 'false', 'no', 'ne', 'off'}:
return False
raise DRFValidationError({field_name: f"Neispravna boolean vrijednost za {field_name}."})
def _parse_recipients(payload, *, fallback_candidates):
raw_recipients = payload.get('recipients')
if raw_recipients is None:
single = payload.get('recipient')
raw_recipients = [single] if single is not None else []
elif isinstance(raw_recipients, str):
raw_recipients = [raw_recipients]
elif not isinstance(raw_recipients, list):
raise DRFValidationError({'recipients': 'Polje recipients mora biti lista email adresa.'})
prepared = []
for item in raw_recipients:
if not isinstance(item, str):
raise DRFValidationError({'recipients': 'Svaki primatelj mora biti email adresa.'})
chunks = [chunk.strip() for chunk in item.split(',')]
prepared.extend(chunk for chunk in chunks if chunk)
if not prepared:
prepared = [candidate for candidate in fallback_candidates if isinstance(candidate, str) and candidate.strip()]
unique = OrderedDict()
for email in prepared:
normalized = email.strip()
if not normalized:
continue
try:
validate_email(normalized)
except DjangoValidationError:
raise DRFValidationError({'recipients': f'Neispravna email adresa: {normalized}.'})
unique[normalized.lower()] = normalized
return list(unique.values())
def _build_work_order_invoices_pdf_bytes(work_order):
try:
task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
payload = task_result.get(timeout=45)
except KombuOperationalError:
payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get()
except CeleryTimeoutError:
raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."})
if not isinstance(payload, dict) or payload.get('error'):
raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."})
pdf_b64 = payload.get('pdf_base64')
if not pdf_b64:
raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."})
try:
return base64.b64decode(pdf_b64)
except (ValueError, TypeError):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
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):
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 _parse_month_value(value):
if value in (None, ''):
today = timezone.localdate()
return today.year, today.month, today.strftime('%Y-%m')
normalized = str(value).strip()
parts = normalized.split('-')
if len(parts) != 2:
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
try:
year = int(parts[0])
month = int(parts[1])
except ValueError:
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
if year < 2000 or year > 2100 or month < 1 or month > 12:
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
return year, month, f"{year:04d}-{month:02d}"
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_service_note_for_email(*, user, note_id, work_order):
if note_id in (None, ''):
return None
note = ServiceContextNote.objects.select_related('created_by', 'recipient', 'work_order').filter(
pk=note_id,
is_active=True,
).first()
if note is None:
raise DRFValidationError({'service_note_id': 'Odabrana bilješka ne postoji ili nije aktivna.'})
if note.work_order_id and str(note.work_order_id) != str(work_order.pk):
raise DRFValidationError({'service_note_id': 'Bilješka nije povezana s ovim putnim nalogom.'})
if not (user.is_staff or str(note.created_by_id) == str(user.id) or str(note.recipient_id) == str(user.id)):
raise DRFValidationError({'service_note_id': 'Nemate dozvolu za korištenje odabrane bilješke.'})
return note
def _send_email_with_attachments(*, recipients, subject, body, attachments):
if not recipients:
raise DRFValidationError({'recipients': 'Nije pronađena nijedna email adresa za slanje.'})
message = EmailMessage(
subject=subject,
body=body,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None),
to=recipients,
)
for filename, content, content_type in attachments:
message.attach(filename, content, content_type)
message.send(fail_silently=False)
def _dispatch_work_order_email_background(task_kwargs):
try:
send_work_order_email_bundle_task.delay(**task_kwargs)
return 'celery'
except KombuOperationalError:
pass
def _run_fallback():
try:
send_work_order_email_bundle_task.run(**task_kwargs)
except Exception:
logger.exception("Greška u fallback background slanju emaila za putni nalog.")
thread = threading.Thread(target=_run_fallback, name='work-order-email-fallback', daemon=True)
thread.start()
return 'thread'
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def pusher_auth(request):
@@ -1659,26 +1868,8 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested')
try:
task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
payload = task_result.get(timeout=45)
except KombuOperationalError:
payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get()
except CeleryTimeoutError:
raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."})
if not isinstance(payload, dict) or payload.get('error'):
raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."})
pdf_b64 = payload.get('pdf_base64')
if not pdf_b64:
raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."})
try:
pdf_bytes = base64.b64decode(pdf_b64)
except (ValueError, TypeError):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
filename = payload.get('filename') or _pdf_filename(work_order, 'invoices')
pdf_bytes = _build_work_order_invoices_pdf_bytes(work_order)
filename = _pdf_filename(work_order, 'invoices')
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -1855,25 +2046,130 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
work_order = self.get_object()
recipient = _first_non_empty(
request.data.get('recipient'),
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
getattr(work_order.creator, 'email', None),
recipients = _parse_recipients(
request.data,
fallback_candidates=[
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
getattr(work_order.creator, 'email', None),
],
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
if not recipients:
raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_work_order_pdf(work_order)
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga."
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=_pdf_filename(work_order, 'work_order'),
pdf_bytes=pdf_bytes,
include_work_order_pdf = _parse_boolean_flag(
request.data.get('include_work_order_pdf'),
field_name='include_work_order_pdf',
default=True,
)
include_service_records_pdf = _parse_boolean_flag(
request.data.get('include_service_records_pdf'),
field_name='include_service_records_pdf',
default=False,
)
include_invoices_pdf = _parse_boolean_flag(
request.data.get('include_invoices_pdf'),
field_name='include_invoices_pdf',
default=False,
)
include_images = _parse_boolean_flag(
request.data.get('include_images'),
field_name='include_images',
default=False,
)
include_monthly_tasks = _parse_boolean_flag(
request.data.get('include_monthly_tasks'),
field_name='include_monthly_tasks',
default=False,
)
selected_note = _resolve_service_note_for_email(
user=request.user,
note_id=request.data.get('service_note_id') or request.data.get('note_id'),
work_order=work_order,
)
if not any([
include_work_order_pdf,
include_service_records_pdf,
include_invoices_pdf,
include_images,
include_monthly_tasks,
]):
raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."})
month_year = None
month_number = None
month_key = ''
if include_monthly_tasks:
month_year, month_number, month_key = _parse_month_value(request.data.get('month'))
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga."
selected_attachment_flags = []
if include_work_order_pdf:
selected_attachment_flags.append({'type': 'work_order_pdf'})
if include_service_records_pdf:
selected_attachment_flags.append({'type': 'service_records_pdf'})
if include_invoices_pdf:
selected_attachment_flags.append({'type': 'invoices_pdf'})
if include_images:
selected_attachment_flags.append({'type': 'images'})
if include_monthly_tasks:
selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key})
dispatch_log = EmailDispatchLog.objects.create(
dispatch_type='work_order',
requested_by=request.user,
work_order=work_order,
recipients=recipients,
subject=subject,
message=message,
attachments=selected_attachment_flags,
status='queued',
metadata={
'service_note_id': str(selected_note.pk) if selected_note else None,
},
)
NotificationService.create_notification(
recipient=request.user,
title='Slanje emaila u obradi',
message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.',
level='info',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'requested',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients),
},
)
dispatch_backend = _dispatch_work_order_email_background(
{
'work_order_id': str(work_order.pk),
'requested_by_id': str(request.user.id),
'recipients': recipients,
'subject': subject,
'message': message,
'include_work_order_pdf': include_work_order_pdf,
'include_service_records_pdf': include_service_records_pdf,
'include_invoices_pdf': include_invoices_pdf,
'include_images': include_images,
'include_monthly_tasks': include_monthly_tasks,
'month_year': month_year,
'month_number': month_number,
'month_key': month_key,
'service_note_id': str(selected_note.pk) if selected_note else None,
'dispatch_log_id': str(dispatch_log.pk),
}
)
return Response(
{
"queued": True,
"recipients": recipients,
"work_order_id": str(work_order.pk),
"dispatch_backend": dispatch_backend,
},
status=status.HTTP_202_ACCEPTED,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
@@ -1902,7 +2198,6 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_service_record_pdf(service_record)
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
body = _first_non_empty(request.data.get('message')) or (
@@ -1915,6 +2210,17 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
filename=f"{service_record.pk}.service-record.pdf",
pdf_bytes=pdf_bytes,
)
EmailDispatchLog.objects.create(
dispatch_type='service_record',
requested_by=request.user,
service_record=service_record,
recipients=[recipient],
subject=subject,
message=body,
attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}],
status='sent',
sent_at=timezone.now(),
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)