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:
@@ -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
|
||||
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal file
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal file
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal 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()
|
||||
@@ -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