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

@@ -10,6 +10,7 @@ from .models import (
WorkOrderAdditionalCostsTable,
ServiceContextNote,
GeneratedWorkOrderPdf,
EmailDispatchLog,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -244,4 +245,50 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin):
def short_description(self, obj):
if obj.description:
return obj.description[:60] + ('' if len(obj.description) > 60 else '')
return ""
return ""
@admin.register(EmailDispatchLog)
class EmailDispatchLogAdmin(admin.ModelAdmin):
list_display = (
'id',
'dispatch_type',
'status',
'requested_by',
'work_order',
'service_record',
'recipients_count',
'sent_at',
'created_at',
)
list_filter = ('dispatch_type', 'status', 'created_at', 'sent_at')
search_fields = (
'subject',
'message',
'error_message',
'requested_by__email',
'requested_by__username',
'work_order__display_code',
)
readonly_fields = (
'dispatch_type',
'requested_by',
'work_order',
'service_record',
'recipients',
'subject',
'message',
'attachments',
'status',
'error_message',
'metadata',
'sent_at',
'created_at',
'updated_at',
)
ordering = ('-created_at',)
@admin.display(description='Primatelja')
def recipients_count(self, obj):
recipients = obj.recipients or []
return len(recipients) if isinstance(recipients, list) else 0

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.15 on 2026-07-21 11:50
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0032_servicecontextnote'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailDispatchLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('dispatch_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_record', 'Servisni zapis')], db_index=True, max_length=32, verbose_name='Tip slanja')),
('recipients', models.JSONField(blank=True, default=list, verbose_name='Primatelji')),
('subject', models.CharField(blank=True, max_length=255, verbose_name='Naslov')),
('message', models.TextField(blank=True, verbose_name='Poruka')),
('attachments', models.JSONField(blank=True, default=list, verbose_name='Privitci')),
('status', models.CharField(choices=[('queued', 'U obradi'), ('sent', 'Poslano'), ('failed', 'Neuspješno')], db_index=True, default='queued', max_length=16, verbose_name='Status')),
('error_message', models.TextField(blank=True, verbose_name='Greška')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Metapodaci')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Poslano u')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_email_dispatch_logs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('service_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Log slanja emaila',
'verbose_name_plural': 'Logovi slanja emaila',
'ordering': ['-created_at'],
},
),
]

View File

@@ -568,6 +568,67 @@ class VehicleNotification(BaseModel):
return f"{self.title} -> {self.recipient} ({self.level})"
class EmailDispatchLog(BaseModel):
DISPATCH_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_record', _("Servisni zapis")),
]
STATUS_CHOICES = [
('queued', _("U obradi")),
('sent', _("Poslano")),
('failed', _("Neuspješno")),
]
dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja"))
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_email_dispatch_logs',
verbose_name=_("Zatražio"),
)
work_order = models.ForeignKey(
'fleet.WorkOrder',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Putni nalog"),
)
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Servisni zapis"),
)
recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji"))
subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov"))
message = models.TextField(blank=True, verbose_name=_("Poruka"))
attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status"))
error_message = models.TextField(blank=True, verbose_name=_("Greška"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u"))
class Meta:
verbose_name = _("Log slanja emaila")
verbose_name_plural = _("Logovi slanja emaila")
ordering = ['-created_at']
def __str__(self):
return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})"
def clean(self):
super().clean()
if self.dispatch_type == 'work_order' and not self.work_order_id:
raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")})
if self.dispatch_type == 'service_record' and not self.service_record_id:
raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")})
class ServiceContextNote(BaseModel):
AUDIENCE_SOURCE_CHOICES = [
('self', 'Samo autor'),

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)

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()

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)