feat: async PDF cache i API media putanje za putne naloge
Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from .models import (
|
||||
WorkOrder,
|
||||
WorkOrderPhoto,
|
||||
WorkOrderInvoice,
|
||||
GeneratedWorkOrderPdf,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
VehicleServiceAttachment,
|
||||
@@ -194,6 +195,15 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
||||
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
||||
ordering = ('-datum', '-created_at')
|
||||
|
||||
|
||||
@admin.register(GeneratedWorkOrderPdf)
|
||||
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
|
||||
list_filter = ('pdf_type', 'status', 'is_active', 'expires_at')
|
||||
search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
ordering = ('-created_at',)
|
||||
|
||||
@admin.register(VehicleServiceRecord)
|
||||
class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import modules.fleet.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0022_workorderphoto'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GeneratedWorkOrderPdf',
|
||||
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')),
|
||||
('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')),
|
||||
('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_work_order_pdf_upload_to, verbose_name='PDF datoteka')),
|
||||
('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')),
|
||||
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Poruka greške')),
|
||||
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
|
||||
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Generirani PDF putnog naloga',
|
||||
'verbose_name_plural': 'Generirani PDF-ovi putnih naloga',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-17 14:32
|
||||
|
||||
import modules.fleet.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0023_generatedworkorderpdf'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorderphoto',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderphoto',
|
||||
name='id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderphoto',
|
||||
name='image',
|
||||
field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderphoto',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||
),
|
||||
]
|
||||
@@ -54,6 +54,15 @@ def _work_order_photo_upload_to(instance, filename):
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
|
||||
|
||||
def _generated_work_order_pdf_upload_to(instance, filename):
|
||||
vehicle_reg = 'unknown'
|
||||
try:
|
||||
vehicle_reg = instance.work_order.vehicle.registration_number
|
||||
except Exception:
|
||||
vehicle_reg = 'unknown'
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}"
|
||||
|
||||
|
||||
class VehicleServicePhoto(BaseModel):
|
||||
"""
|
||||
@@ -384,6 +393,48 @@ class WorkOrderPhoto(BaseModel):
|
||||
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
|
||||
|
||||
|
||||
class GeneratedWorkOrderPdf(BaseModel):
|
||||
PDF_TYPE_CHOICES = [
|
||||
('work_order', _("Putni nalog")),
|
||||
('service_records', _("Servisni zapisi")),
|
||||
('invoices', _("Računi")),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('pending', _("U obradi")),
|
||||
('ready', _("Spremno")),
|
||||
('failed', _("Neuspješno")),
|
||||
]
|
||||
|
||||
work_order = models.ForeignKey(
|
||||
WorkOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generated_pdfs',
|
||||
verbose_name=_("Putni nalog"),
|
||||
)
|
||||
requested_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='requested_generated_work_order_pdfs',
|
||||
verbose_name=_("Zatražio"),
|
||||
)
|
||||
pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a"))
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status"))
|
||||
file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF datoteka"))
|
||||
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
|
||||
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
|
||||
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Generirani PDF putnog naloga")
|
||||
verbose_name_plural = _("Generirani PDF-ovi putnih naloga")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pdf_type}:{self.work_order_id}:{self.status}"
|
||||
|
||||
|
||||
class VehicleServiceRecord(BaseModel):
|
||||
"""
|
||||
Zapisi o servisima/popravcima na vozilu.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
import re
|
||||
from django.urls import reverse
|
||||
from .models import (
|
||||
Crane,
|
||||
Vehicle,
|
||||
@@ -183,6 +184,8 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkOrderInvoice
|
||||
fields = [
|
||||
@@ -193,11 +196,22 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
'datum',
|
||||
'opis',
|
||||
'image',
|
||||
'image_url',
|
||||
'created_by',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_by', 'created_at']
|
||||
|
||||
def get_image_url(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not obj.image:
|
||||
return None
|
||||
if request is None:
|
||||
return None
|
||||
return request.build_absolute_uri(
|
||||
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
||||
)
|
||||
|
||||
|
||||
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -3,6 +3,7 @@ from celery import shared_task
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.files.base import ContentFile
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import base64
|
||||
@@ -10,7 +11,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
from .models import VehicleNotification
|
||||
from .models import VehicleNotification, GeneratedWorkOrderPdf
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -205,4 +206,106 @@ def build_work_order_invoices_pdf_task(work_order_id):
|
||||
return {
|
||||
"filename": filename,
|
||||
"pdf_base64": pdf_b64,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_generated_pdfs_task():
|
||||
now = timezone.now()
|
||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
expires_at__isnull=False,
|
||||
expires_at__lte=now,
|
||||
)
|
||||
deleted = 0
|
||||
for item in expired:
|
||||
if item.file:
|
||||
item.file.delete(save=False)
|
||||
item.is_active = False
|
||||
item.status = 'failed'
|
||||
item.error_message = 'PDF cache istekao.'
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@shared_task
|
||||
def build_work_order_pdf_cached_task(generated_pdf_id):
|
||||
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
||||
from .services import NotificationService
|
||||
|
||||
generated = (
|
||||
GeneratedWorkOrderPdf.objects
|
||||
.select_related('work_order', 'work_order__vehicle', 'requested_by')
|
||||
.filter(pk=generated_pdf_id, is_active=True)
|
||||
.first()
|
||||
)
|
||||
if generated is None:
|
||||
return {"error": "Generated PDF zapis nije pronađen."}
|
||||
|
||||
work_order = generated.work_order
|
||||
|
||||
try:
|
||||
if generated.pdf_type == 'invoices':
|
||||
payload = build_work_order_invoices_pdf_task(str(work_order.pk))
|
||||
if not isinstance(payload, dict) or payload.get('error'):
|
||||
raise ValueError(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 ValueError('PDF sadržaj nije dostupan.')
|
||||
pdf_bytes = base64.b64decode(pdf_b64)
|
||||
elif generated.pdf_type == 'service_records':
|
||||
pdf_bytes = _build_work_order_service_records_pdf(work_order)
|
||||
else:
|
||||
pdf_bytes = _build_work_order_pdf(work_order)
|
||||
|
||||
filename = generated.filename or f"{work_order.pk}.{generated.pdf_type}.pdf"
|
||||
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
|
||||
generated.status = 'ready'
|
||||
generated.error_message = ''
|
||||
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
if generated.requested_by:
|
||||
NotificationService.create_notification(
|
||||
recipient=generated.requested_by,
|
||||
title="PDF spreman",
|
||||
message=f"PDF dokument je uspješno generiran za WO-{work_order.pk}.",
|
||||
level="success",
|
||||
send_email=False,
|
||||
metadata={
|
||||
"entity_type": "work_order_pdf",
|
||||
"work_order_id": str(work_order.pk),
|
||||
"vehicle_id": str(work_order.vehicle_id),
|
||||
"section": "work-orders",
|
||||
"pdf_type": generated.pdf_type,
|
||||
"stage": "completed",
|
||||
"generated_pdf_id": str(generated.pk),
|
||||
"download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
|
||||
"filename": generated.filename or filename,
|
||||
"expires_at": generated.expires_at.isoformat() if generated.expires_at else None,
|
||||
},
|
||||
)
|
||||
return {"status": "ready", "generated_pdf_id": str(generated.pk)}
|
||||
except Exception as exc:
|
||||
generated.status = 'failed'
|
||||
generated.error_message = str(exc)
|
||||
generated.save(update_fields=['status', 'error_message', 'updated_at'])
|
||||
if generated.requested_by:
|
||||
NotificationService.create_notification(
|
||||
recipient=generated.requested_by,
|
||||
title="Greška kod PDF-a",
|
||||
message=f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}.",
|
||||
level="warning",
|
||||
send_email=False,
|
||||
metadata={
|
||||
"entity_type": "work_order_pdf",
|
||||
"work_order_id": str(work_order.pk),
|
||||
"vehicle_id": str(work_order.vehicle_id),
|
||||
"section": "work-orders",
|
||||
"pdf_type": generated.pdf_type,
|
||||
"stage": "failed",
|
||||
"generated_pdf_id": str(generated.pk),
|
||||
},
|
||||
)
|
||||
logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleServicePhoto
|
||||
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto
|
||||
|
||||
|
||||
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
||||
@@ -94,3 +94,47 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
response = self.client.get(service_photos_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_work_order_images_endpoint_returns_api_file_urls(self):
|
||||
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
|
||||
create_response = self.client.post(
|
||||
upload_url,
|
||||
data={
|
||||
'image': create_test_image('workorder-url.jpg'),
|
||||
'description': 'URL test',
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201, create_response.data)
|
||||
|
||||
list_response = self.client.get(upload_url)
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
images = list_response.data.get('images', [])
|
||||
self.assertEqual(len(images), 1)
|
||||
|
||||
photo_id = images[0]['id']
|
||||
self.assertIn('/api/fleet/work-orders/', images[0]['original_url'])
|
||||
self.assertIn(f'/images/{photo_id}/file', images[0]['original_url'])
|
||||
|
||||
file_response = self.client.get(images[0]['original_url'])
|
||||
self.assertEqual(file_response.status_code, 200)
|
||||
self.assertTrue(file_response['Content-Type'].startswith('image/'))
|
||||
|
||||
def test_work_order_invoice_serializer_exposes_image_api_url(self):
|
||||
invoice = WorkOrderInvoice.objects.create(
|
||||
work_order=self.work_order,
|
||||
naziv_racuna='Test račun',
|
||||
image=create_test_image('invoice.jpg'),
|
||||
created_by=self.user,
|
||||
)
|
||||
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
|
||||
list_response = self.client.get(list_url)
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(len(list_response.data), 1)
|
||||
image_url = list_response.data[0].get('image_url')
|
||||
self.assertIsNotNone(image_url)
|
||||
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
|
||||
|
||||
image_response = self.client.get(image_url)
|
||||
self.assertEqual(image_response.status_code, 200)
|
||||
self.assertTrue(image_response['Content-Type'].startswith('image/'))
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
@@ -31,6 +32,7 @@ from .models import (
|
||||
VehicleNotification,
|
||||
WorkOrder,
|
||||
WorkOrderPhoto,
|
||||
GeneratedWorkOrderPdf,
|
||||
WorkOrderInvoice,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
@@ -53,7 +55,11 @@ from .services import (
|
||||
VehicleServicePhotoService,
|
||||
NotificationService,
|
||||
)
|
||||
from .tasks import build_work_order_invoices_pdf_task
|
||||
from .tasks import (
|
||||
build_work_order_invoices_pdf_task,
|
||||
build_work_order_pdf_cached_task,
|
||||
cleanup_expired_generated_pdfs_task,
|
||||
)
|
||||
|
||||
register_unicode_fonts()
|
||||
|
||||
@@ -132,6 +138,165 @@ def _parse_format(value):
|
||||
extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
|
||||
return supported[selected], extension
|
||||
|
||||
|
||||
GENERATED_PDF_TTL_HOURS = 24
|
||||
|
||||
|
||||
def _pdf_filename(work_order, pdf_type):
|
||||
if pdf_type == 'invoices':
|
||||
return f"{work_order.pk}.work-order-invoices.pdf"
|
||||
if pdf_type == 'service_records':
|
||||
return f"{work_order.pk}.work-order-service-records.pdf"
|
||||
return f"{work_order.pk}.work-order.pdf"
|
||||
|
||||
|
||||
def _get_cached_pdf(work_order, pdf_type):
|
||||
now = timezone.now()
|
||||
return (
|
||||
GeneratedWorkOrderPdf.objects
|
||||
.filter(
|
||||
is_active=True,
|
||||
work_order=work_order,
|
||||
pdf_type=pdf_type,
|
||||
status='ready',
|
||||
expires_at__gt=now,
|
||||
)
|
||||
.exclude(file='')
|
||||
.exclude(file__isnull=True)
|
||||
.order_by('-created_at')
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_expired_generated_pdfs():
|
||||
now = timezone.now()
|
||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
expires_at__isnull=False,
|
||||
expires_at__lte=now,
|
||||
)
|
||||
for item in expired:
|
||||
if item.file:
|
||||
item.file.delete(save=False)
|
||||
item.is_active = False
|
||||
item.status = 'failed'
|
||||
item.error_message = 'PDF cache istekao.'
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||
generated_pdf.file.open('rb')
|
||||
filename = generated_pdf.filename or default_filename
|
||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
response['Cache-Control'] = 'private, max-age=3600'
|
||||
return response
|
||||
|
||||
|
||||
def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None):
|
||||
if user is None:
|
||||
return
|
||||
if doc_type == 'work_order':
|
||||
doc_label = "putnog naloga"
|
||||
elif doc_type == 'service_records':
|
||||
doc_label = "servisnih zapisa putnog naloga"
|
||||
else:
|
||||
doc_label = "računa putnog naloga"
|
||||
|
||||
if stage == 'requested':
|
||||
title = f"Zahtjev za PDF {doc_label}"
|
||||
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
|
||||
level = "info"
|
||||
elif stage == 'failed':
|
||||
title = f"Greška kod PDF-a ({doc_label})"
|
||||
message = f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}."
|
||||
level = "warning"
|
||||
else:
|
||||
title = f"PDF spreman ({doc_label})"
|
||||
message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
|
||||
level = "success"
|
||||
|
||||
metadata = {
|
||||
"entity_type": "work_order_pdf",
|
||||
"work_order_id": str(work_order.pk),
|
||||
"vehicle_id": str(work_order.vehicle_id),
|
||||
"section": "work-orders",
|
||||
"pdf_type": doc_type,
|
||||
"stage": stage,
|
||||
}
|
||||
if generated_pdf and generated_pdf.pk:
|
||||
metadata["generated_pdf_id"] = str(generated_pdf.pk)
|
||||
metadata["download_url"] = (
|
||||
f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/"
|
||||
)
|
||||
metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type)
|
||||
if generated_pdf.expires_at:
|
||||
metadata["expires_at"] = generated_pdf.expires_at.isoformat()
|
||||
|
||||
NotificationService.create_notification(
|
||||
recipient=user,
|
||||
title=title,
|
||||
message=message,
|
||||
level=level,
|
||||
send_email=False,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _request_cached_pdf_generation(*, request, work_order, pdf_type):
|
||||
_cleanup_expired_generated_pdfs()
|
||||
cached = _get_cached_pdf(work_order, pdf_type)
|
||||
if cached:
|
||||
_notify_pdf_request(
|
||||
user=request.user,
|
||||
work_order=work_order,
|
||||
doc_type=pdf_type,
|
||||
stage='completed',
|
||||
generated_pdf=cached,
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
"generated_pdf_id": str(cached.pk),
|
||||
"download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/",
|
||||
"filename": cached.filename or _pdf_filename(work_order, pdf_type),
|
||||
"expires_at": cached.expires_at.isoformat() if cached.expires_at else None,
|
||||
}
|
||||
|
||||
existing_pending = GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
work_order=work_order,
|
||||
pdf_type=pdf_type,
|
||||
status='pending',
|
||||
).order_by('-created_at').first()
|
||||
|
||||
if existing_pending:
|
||||
return {
|
||||
"status": "processing",
|
||||
"generated_pdf_id": str(existing_pending.pk),
|
||||
}
|
||||
|
||||
generated_pdf = GeneratedWorkOrderPdf.objects.create(
|
||||
work_order=work_order,
|
||||
requested_by=request.user,
|
||||
pdf_type=pdf_type,
|
||||
status='pending',
|
||||
filename=_pdf_filename(work_order, pdf_type),
|
||||
expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS),
|
||||
)
|
||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested')
|
||||
|
||||
try:
|
||||
build_work_order_pdf_cached_task.delay(str(generated_pdf.pk))
|
||||
cleanup_expired_generated_pdfs_task.delay()
|
||||
except KombuOperationalError:
|
||||
build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get()
|
||||
cleanup_expired_generated_pdfs_task.apply().get()
|
||||
|
||||
return {
|
||||
"status": "processing",
|
||||
"generated_pdf_id": str(generated_pdf.pk),
|
||||
}
|
||||
|
||||
def _build_simple_pdf(title, rows):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
@@ -1160,41 +1325,6 @@ def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
|
||||
message.send(fail_silently=False)
|
||||
|
||||
|
||||
def _notify_pdf_request(*, user, work_order, doc_type, stage):
|
||||
if user is None:
|
||||
return
|
||||
if doc_type == 'work_order':
|
||||
doc_label = "putnog naloga"
|
||||
elif doc_type == 'service_records':
|
||||
doc_label = "servisnih zapisa putnog naloga"
|
||||
else:
|
||||
doc_label = "računa putnog naloga"
|
||||
|
||||
if stage == 'requested':
|
||||
title = f"Zahtjev za PDF {doc_label}"
|
||||
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
|
||||
level = "info"
|
||||
else:
|
||||
title = f"PDF spreman ({doc_label})"
|
||||
message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
|
||||
level = "success"
|
||||
|
||||
NotificationService.create_notification(
|
||||
recipient=user,
|
||||
title=title,
|
||||
message=message,
|
||||
level=level,
|
||||
send_email=False,
|
||||
metadata={
|
||||
"entity_type": "work_order_pdf",
|
||||
"work_order_id": str(work_order.pk),
|
||||
"vehicle_id": str(work_order.vehicle_id),
|
||||
"section": "work-orders",
|
||||
"pdf_type": doc_type,
|
||||
"stage": stage,
|
||||
},
|
||||
)
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def pusher_auth(request):
|
||||
@@ -1273,13 +1403,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
images = []
|
||||
for photo in photos_qs:
|
||||
original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
|
||||
file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk})
|
||||
original_url = request.build_absolute_uri(file_path)
|
||||
optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}")
|
||||
images.append({
|
||||
"id": photo.pk,
|
||||
"work_order_id": photo.work_order_id,
|
||||
"description": photo.description,
|
||||
"original_url": original_url,
|
||||
"optimized_url": original_url,
|
||||
"optimized_url": optimized_url,
|
||||
"created_at": photo.created_at,
|
||||
})
|
||||
|
||||
@@ -1292,9 +1424,65 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
"images": images,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path=r'images/(?P<photo_id>[^/.]+)/file')
|
||||
def image_file(self, request, pk=None, photo_id=None):
|
||||
work_order = self.get_object()
|
||||
photo = WorkOrderPhoto.objects.filter(
|
||||
is_active=True,
|
||||
work_order_id=work_order.pk,
|
||||
pk=photo_id,
|
||||
).first()
|
||||
if photo is None or not photo.image:
|
||||
raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."})
|
||||
|
||||
width = _parse_positive_int(
|
||||
request.query_params.get('w'),
|
||||
field_name='w',
|
||||
default=1280,
|
||||
min_value=160,
|
||||
max_value=3840,
|
||||
)
|
||||
quality = _parse_positive_int(
|
||||
request.query_params.get('q'),
|
||||
field_name='q',
|
||||
default=75,
|
||||
min_value=30,
|
||||
max_value=95,
|
||||
)
|
||||
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
||||
|
||||
try:
|
||||
photo.image.open('rb')
|
||||
with Image.open(photo.image) as source:
|
||||
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
||||
if width and image.width > width:
|
||||
ratio = width / float(image.width)
|
||||
target_height = max(1, int(image.height * ratio))
|
||||
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
buffer = BytesIO()
|
||||
save_kwargs = {"format": image_format, "optimize": True}
|
||||
if image_format in ('WEBP', 'JPEG'):
|
||||
save_kwargs["quality"] = quality
|
||||
image.save(buffer, **save_kwargs)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
||||
finally:
|
||||
photo.image.close()
|
||||
|
||||
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
||||
response['Cache-Control'] = 'private, max-age=86400'
|
||||
response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='pdf')
|
||||
def pdf(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
_cleanup_expired_generated_pdfs()
|
||||
cached = _get_cached_pdf(work_order, 'work_order')
|
||||
if cached:
|
||||
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order'))
|
||||
|
||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested')
|
||||
pdf_bytes = _build_work_order_pdf(work_order)
|
||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
|
||||
@@ -1302,9 +1490,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='pdf-request')
|
||||
def pdf_request(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order')
|
||||
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='service-records-pdf')
|
||||
def service_records_pdf(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
_cleanup_expired_generated_pdfs()
|
||||
cached = _get_cached_pdf(work_order, 'service_records')
|
||||
if cached:
|
||||
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
|
||||
|
||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
|
||||
pdf_bytes = _build_work_order_service_records_pdf(work_order)
|
||||
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
|
||||
@@ -1312,9 +1511,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
||||
def service_records_pdf_request(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records')
|
||||
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='invoices-pdf')
|
||||
def invoices_pdf(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
_cleanup_expired_generated_pdfs()
|
||||
cached = _get_cached_pdf(work_order, 'invoices')
|
||||
if cached:
|
||||
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))
|
||||
@@ -1341,13 +1551,34 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='invoices-pdf-request')
|
||||
def invoices_pdf_request(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices')
|
||||
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P<pdf_id>[^/.]+)/download')
|
||||
def generated_pdf_download(self, request, pk=None, pdf_id=None):
|
||||
work_order = self.get_object()
|
||||
_cleanup_expired_generated_pdfs()
|
||||
generated_pdf = GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
work_order=work_order,
|
||||
pk=pdf_id,
|
||||
status='ready',
|
||||
expires_at__gt=timezone.now(),
|
||||
).exclude(file='').exclude(file__isnull=True).first()
|
||||
if generated_pdf is None:
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
||||
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||
def pdf_preview(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
||||
invoice_rows = []
|
||||
for invoice in invoices:
|
||||
image_url = request.build_absolute_uri(invoice.image.url) if invoice.image else None
|
||||
image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None
|
||||
datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
|
||||
invoice_rows.append(
|
||||
f"""
|
||||
@@ -1535,6 +1766,57 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
||||
qs = qs.filter(work_order_id=work_order_id)
|
||||
return qs
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='image')
|
||||
def image(self, request, pk=None):
|
||||
invoice = self.get_object()
|
||||
if not invoice.image:
|
||||
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
|
||||
|
||||
width = _parse_positive_int(
|
||||
request.query_params.get('w'),
|
||||
field_name='w',
|
||||
default=1280,
|
||||
min_value=160,
|
||||
max_value=3840,
|
||||
)
|
||||
quality = _parse_positive_int(
|
||||
request.query_params.get('q'),
|
||||
field_name='q',
|
||||
default=75,
|
||||
min_value=30,
|
||||
max_value=95,
|
||||
)
|
||||
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
||||
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
||||
if width and image.width > width:
|
||||
ratio = width / float(image.width)
|
||||
target_height = max(1, int(image.height * ratio))
|
||||
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
buffer = BytesIO()
|
||||
save_kwargs = {"format": image_format, "optimize": True}
|
||||
if image_format in ('WEBP', 'JPEG'):
|
||||
save_kwargs["quality"] = quality
|
||||
image.save(buffer, **save_kwargs)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
||||
response['Cache-Control'] = 'private, max-age=86400'
|
||||
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"'
|
||||
return response
|
||||
|
||||
def perform_create(self, serializer):
|
||||
work_order = serializer.validated_data.get('work_order')
|
||||
if work_order is None:
|
||||
|
||||
Reference in New Issue
Block a user