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