feat: async PDF cache i API media putanje za putne naloge
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

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:
mariomitte
2026-07-17 16:42:02 +02:00
parent 5e7ec72a4e
commit e8330473cd
17 changed files with 726 additions and 231 deletions

View File

@@ -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')

View File

@@ -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'],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)}

View File

@@ -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/'))

View File

@@ -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: