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