From 2826ee9babf3a935d56d87b7b302f228b35ed9b2 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Mon, 31 Aug 2026 07:32:05 +0200 Subject: [PATCH] fix: invalidate stale service records PDF cache on service record changes Ensure admin/API deletes and updates of service records, photos, and attachments invalidate the cached service-records PDF. Also harden cached PDF lookup/download paths so a missing file is treated as stale cache instead of a 500. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/modules/fleet/models.py | 63 +++++++++++++++++++ .../tests/test_work_order_images_endpoint.py | 46 ++++++++++++++ backend/modules/fleet/views.py | 46 ++++++++++++-- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index d617578..862509c 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -2,10 +2,12 @@ import uuid import re from datetime import time from django.db import models +from django.db.models.signals import post_save, pre_delete from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from django.utils import timezone +from django.dispatch import receiver from core.base_models import BaseModel from decimal import Decimal @@ -142,6 +144,67 @@ class VehicleServiceAttachment(BaseModel): def __str__(self): return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})" + +def _service_record_work_order_id(instance): + if isinstance(instance, VehicleServiceRecord): + task = getattr(instance, 'task', None) + if task and getattr(task, 'work_order_id', None): + return task.work_order_id + if getattr(instance, 'task_id', None): + return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first() + return None + + service_record = getattr(instance, 'service_record', None) + if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None): + return service_record.task.work_order_id + + service_record_id = getattr(instance, 'service_record_id', None) + if not service_record_id: + return None + return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first() + + +def _invalidate_service_records_pdf_cache(work_order_id): + if not work_order_id: + return + + cached_pdfs = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + work_order_id=work_order_id, + pdf_type='service_records', + ) + for cached in cached_pdfs: + file_name = getattr(cached.file, 'name', '') + if file_name: + try: + if cached.file.storage.exists(file_name): + cached.file.delete(save=False) + except (FileNotFoundError, OSError, ValueError): + pass + cached.file = None + cached.is_active = False + cached.status = 'failed' + cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.' + cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) + + +@receiver(post_save, sender='fleet.VehicleServiceRecord') +@receiver(pre_delete, sender='fleet.VehicleServiceRecord') +def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs): + _invalidate_service_records_pdf_cache(_service_record_work_order_id(instance)) + + +@receiver(post_save, sender='fleet.VehicleServicePhoto') +@receiver(pre_delete, sender='fleet.VehicleServicePhoto') +def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs): + _invalidate_service_records_pdf_cache(_service_record_work_order_id(instance)) + + +@receiver(post_save, sender='fleet.VehicleServiceAttachment') +@receiver(pre_delete, sender='fleet.VehicleServiceAttachment') +def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs): + _invalidate_service_records_pdf_cache(_service_record_work_order_id(instance)) + class Vehicle(BaseModel): ASSET_TYPE_CHOICES = [ ('vehicle', _("Vozilo")), diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index e57b33b..8f311db 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -298,6 +298,52 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertFalse(cached.is_active) self.assertEqual(cached.status, 'failed') + def test_service_record_delete_invalidates_service_records_pdf_cache(self): + cached = GeneratedWorkOrderPdf.objects.create( + work_order=self.work_order, + requested_by=self.user, + pdf_type='service_records', + status='ready', + filename='MT150726.work-order-service-records.pdf', + expires_at=timezone.now() + timedelta(hours=1), + ) + cached.file.save( + 'MT150726.work-order-service-records.pdf', + ContentFile(b'%PDF-1.4 cached service records'), + save=True, + ) + + VehicleServiceRecord.objects.filter(pk=self.service_record.pk).delete() + + cached.refresh_from_db() + self.assertFalse(cached.is_active) + self.assertEqual(cached.status, 'failed') + self.assertFalse(cached.file.name) + + def test_service_records_pdf_rebuilds_when_cached_file_is_missing(self): + cached = GeneratedWorkOrderPdf.objects.create( + work_order=self.work_order, + requested_by=self.user, + pdf_type='service_records', + status='ready', + filename='MT150726.work-order-service-records.pdf', + expires_at=timezone.now() + timedelta(hours=1), + ) + cached.file.save( + 'MT150726.work-order-service-records.pdf', + ContentFile(b'%PDF-1.4 cached service records'), + save=True, + ) + cached.file.storage.delete(cached.file.name) + + response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'application/pdf') + + cached.refresh_from_db() + self.assertFalse(cached.is_active) + self.assertEqual(cached.status, 'failed') + def test_service_records_docx_contains_embedded_service_photos(self): VehicleServicePhoto.objects.create( service_record=self.service_record, diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index ad2db2e..b8c6f06 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -532,10 +532,30 @@ def _get_cached_pdf(work_order, pdf_type): ) for candidate in candidates: if str(candidate.filename or '').strip() == expected_filename: - return candidate + file_name = getattr(candidate.file, 'name', '') + storage = getattr(candidate.file, 'storage', None) + if file_name and storage: + try: + if storage.exists(file_name): + return candidate + except (FileNotFoundError, OSError, ValueError): + pass + candidate.file = None + candidate.is_active = False + candidate.status = 'failed' + candidate.error_message = 'PDF datoteka nije dostupna.' + candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) return None +def _mark_generated_pdf_failed(generated_pdf, error_message): + generated_pdf.file = None + generated_pdf.is_active = False + generated_pdf.status = 'failed' + generated_pdf.error_message = error_message + generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) + + def _cleanup_expired_generated_pdfs(): now = timezone.now() expired = GeneratedWorkOrderPdf.objects.filter( @@ -546,10 +566,11 @@ def _cleanup_expired_generated_pdfs(): for item in expired: if item.file: item.file.delete(save=False) + item.file = None item.is_active = False item.status = 'failed' item.error_message = 'PDF cache istekao.' - item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) def _parse_amount_decimal(value): @@ -572,10 +593,11 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None): for cached in cache_qs: if cached.file: cached.file.delete(save=False) + cached.file = None cached.is_active = False cached.status = 'failed' cached.error_message = 'PDF cache invalidiran zbog promjene podataka.' - cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) def _upsert_additional_cost_row_from_invoice(invoice): @@ -607,7 +629,11 @@ def _upsert_additional_cost_row_from_invoice(invoice): def _cached_pdf_file_response(generated_pdf, *, default_filename): - generated_pdf.file.open('rb') + try: + generated_pdf.file.open('rb') + except (FileNotFoundError, OSError, ValueError): + _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') + raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) filename = generated_pdf.filename or default_filename response = FileResponse(generated_pdf.file, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{filename}"' @@ -3666,6 +3692,18 @@ class WorkOrderViewSet(viewsets.ModelViewSet): ).exclude(file='').exclude(file__isnull=True).first() if generated_pdf is None: raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."}) + file_name = getattr(generated_pdf.file, 'name', '') + storage = getattr(generated_pdf.file, 'storage', None) + if not file_name or storage is None: + _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') + raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) + try: + if not storage.exists(file_name): + _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') + raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) + except (FileNotFoundError, OSError, ValueError): + _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') + raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) 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')