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

@@ -0,0 +1,60 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from modules.fleet.models import GeneratedWorkOrderPdf, Vehicle, VehicleNotification, WorkOrder
from modules.fleet.tasks import send_work_order_email_bundle_task
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderEmailTaskCachedPdfTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='task-mail-user',
email='task-mail-user@example.test',
password='test1234',
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='TASK-CACHE-001',
crane_serial_number='112233',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
@patch('django.core.mail.message.EmailMessage.send', side_effect=Exception('smtp-fail'))
def test_failed_email_creates_cached_pdf_links_in_notification(self, _mock_send):
result = send_work_order_email_bundle_task(
work_order_id=str(self.work_order.pk),
requested_by_id=str(self.user.pk),
recipients=['client@example.test'],
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
)
self.assertEqual(result.get('status'), 'failed')
cached = GeneratedWorkOrderPdf.objects.filter(work_order=self.work_order, pdf_type='work_order', status='ready')
self.assertTrue(cached.exists())
notif = VehicleNotification.objects.filter(
recipient=self.user,
metadata__entity_type='work_order_email',
metadata__stage='failed',
).order_by('-created_at').first()
self.assertIsNotNone(notif)
cached_pdfs = notif.metadata.get('cached_pdfs') if isinstance(notif.metadata, dict) else None
self.assertTrue(isinstance(cached_pdfs, list) and len(cached_pdfs) > 0)
self.assertIn('download_url', cached_pdfs[0])

View File

@@ -0,0 +1,128 @@
import uuid
from datetime import date
from io import BytesIO
from PIL import Image
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from unittest.mock import patch
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder, WorkOrderInvoice, WorkOrderPhoto
from modules.task_management.models import Task
def _test_image(filename='test.jpg', size=(40, 40), color='blue'):
file = BytesIO()
image = Image.new('RGB', size=size, color=color)
image.save(file, 'JPEG')
file.seek(0)
return SimpleUploadedFile(
name=filename,
content=file.getvalue(),
content_type='image/jpeg',
)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderSendEmailTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-mail-user-{suffix}',
email=f'wo-mail-user-{suffix}@example.test',
password='test1234',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-MAIL-001',
crane_serial_number='654321',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
def test_send_email_supports_multiple_recipients_and_all_requested_attachments(self):
WorkOrderPhoto.objects.create(
work_order=self.work_order,
image=_test_image('work-order-photo.jpg'),
description='Work order image',
uploaded_by=self.user,
)
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Račun 1',
image=_test_image('invoice-image.jpg'),
created_by=self.user,
)
note = ServiceContextNote.objects.create(
created_by=self.user,
recipient=self.user,
audience_source='self',
work_order=self.work_order,
note='Odabrana test bilješka.',
)
Task.objects.create(
title='Mjesečni task',
description='Provjera mjesečnog task popisa.',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
scheduled_date=date(2026, 7, 10),
)
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipients': ['client@example.test', 'supervisor@example.test'],
'subject': 'Test slanje dokumentacije',
'message': 'U privitku su svi traženi dokumenti.',
'include_work_order_pdf': True,
'include_service_records_pdf': True,
'include_invoices_pdf': True,
'include_images': True,
'include_monthly_tasks': True,
'month': '2026-07',
'service_note_id': str(note.pk),
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(len(response.data.get('recipients', [])), 2)
mocked_delay.assert_called_once()
call_kwargs = mocked_delay.call_args.kwargs
self.assertEqual(call_kwargs.get('work_order_id'), str(self.work_order.pk))
self.assertEqual(call_kwargs.get('requested_by_id'), str(self.user.id))
self.assertEqual(call_kwargs.get('month_key'), '2026-07')
self.assertEqual(call_kwargs.get('service_note_id'), str(note.pk))
def test_send_email_keeps_backward_compatible_single_recipient_payload(self):
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipient': 'single@example.test',
'subject': 'Legacy send',
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(response.data.get('recipients'), ['single@example.test'])
mocked_delay.assert_called_once()