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>
This commit is contained in:
@@ -2,10 +2,12 @@ import uuid
|
|||||||
import re
|
import re
|
||||||
from datetime import time
|
from datetime import time
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.dispatch import receiver
|
||||||
from core.base_models import BaseModel
|
from core.base_models import BaseModel
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -142,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
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):
|
class Vehicle(BaseModel):
|
||||||
ASSET_TYPE_CHOICES = [
|
ASSET_TYPE_CHOICES = [
|
||||||
('vehicle', _("Vozilo")),
|
('vehicle', _("Vozilo")),
|
||||||
|
|||||||
@@ -298,6 +298,52 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertFalse(cached.is_active)
|
self.assertFalse(cached.is_active)
|
||||||
self.assertEqual(cached.status, 'failed')
|
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):
|
def test_service_records_docx_contains_embedded_service_photos(self):
|
||||||
VehicleServicePhoto.objects.create(
|
VehicleServicePhoto.objects.create(
|
||||||
service_record=self.service_record,
|
service_record=self.service_record,
|
||||||
|
|||||||
@@ -532,10 +532,30 @@ def _get_cached_pdf(work_order, pdf_type):
|
|||||||
)
|
)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if str(candidate.filename or '').strip() == expected_filename:
|
if str(candidate.filename or '').strip() == expected_filename:
|
||||||
|
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
|
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
|
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():
|
def _cleanup_expired_generated_pdfs():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||||
@@ -546,10 +566,11 @@ def _cleanup_expired_generated_pdfs():
|
|||||||
for item in expired:
|
for item in expired:
|
||||||
if item.file:
|
if item.file:
|
||||||
item.file.delete(save=False)
|
item.file.delete(save=False)
|
||||||
|
item.file = None
|
||||||
item.is_active = False
|
item.is_active = False
|
||||||
item.status = 'failed'
|
item.status = 'failed'
|
||||||
item.error_message = 'PDF cache istekao.'
|
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):
|
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:
|
for cached in cache_qs:
|
||||||
if cached.file:
|
if cached.file:
|
||||||
cached.file.delete(save=False)
|
cached.file.delete(save=False)
|
||||||
|
cached.file = None
|
||||||
cached.is_active = False
|
cached.is_active = False
|
||||||
cached.status = 'failed'
|
cached.status = 'failed'
|
||||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
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):
|
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):
|
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||||
|
try:
|
||||||
generated_pdf.file.open('rb')
|
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
|
filename = generated_pdf.filename or default_filename
|
||||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
@@ -3666,6 +3692,18 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
).exclude(file='').exclude(file__isnull=True).first()
|
).exclude(file='').exclude(file__isnull=True).first()
|
||||||
if generated_pdf is None:
|
if generated_pdf is None:
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
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))
|
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')
|
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||||
|
|||||||
Reference in New Issue
Block a user