2 Commits

Author SHA1 Message Date
mariomitte
1e68c663f2 fix: scope inferred work-order tasks to the current work order
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
Prevent service-record inference from pulling tasks from other work orders
that share the same vehicle. This keeps task-service-context, service
reports, and related work-order widgets isolated to the selected nalog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 08:32:41 +02:00
mariomitte
2826ee9bab 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>
2026-08-31 07:32:05 +02:00
3 changed files with 184 additions and 5 deletions

View File

@@ -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")),

View File

@@ -26,7 +26,11 @@ from modules.fleet.models import (
) )
from modules.task_management.models import Task, TaskWorkHoursTable from modules.task_management.models import Task, TaskWorkHoursTable
from modules.fleet.tasks import build_monthly_archive_cached_task from modules.fleet.tasks import build_monthly_archive_cached_task
from modules.fleet.views import _build_monthly_servicer_report_rows, _calculate_daily_quantity_from_hours from modules.fleet.views import (
_build_monthly_servicer_report_rows,
_calculate_daily_quantity_from_hours,
_work_order_related_tasks_queryset,
)
def create_test_image(filename='test.jpg', size=(40, 40), color='red'): def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
@@ -236,6 +240,33 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('additional_costs_table', payload) self.assertIn('additional_costs_table', payload)
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00') self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
def test_work_order_related_tasks_queryset_excludes_other_work_orders_for_same_vehicle(self):
other_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT160726',
purpose='kontrola',
)
other_task = Task.objects.create(
title='Drugi nalog isti stroj',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=other_work_order,
scheduled_date=date(2033, 12, 25),
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=other_task,
performed_by=self.user,
description='Ne smije se pojaviti u prvom nalogu.',
service_title='Krivi nalog',
)
resolved_ids = list(_work_order_related_tasks_queryset(self.work_order).values_list('id', flat=True))
self.assertIn(self.task.pk, resolved_ids)
self.assertNotIn(other_task.pk, resolved_ids)
def test_invoice_upload_immediately_appears_in_additional_costs_table(self): def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
create_response = self.client.post( create_response = self.client.post(
reverse('work-order-invoice-list'), reverse('work-order-invoice-list'),
@@ -298,6 +329,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,

View File

@@ -155,6 +155,7 @@ def _work_order_related_tasks_queryset(work_order):
vehicle_id=work_order.vehicle_id, vehicle_id=work_order.vehicle_id,
task_id__isnull=False, task_id__isnull=False,
task__is_active=True, task__is_active=True,
task__work_order=work_order,
).values_list('task_id', flat=True) ).values_list('task_id', flat=True)
) )
task_ids = list({*direct_task_ids, *inferred_task_ids}) task_ids = list({*direct_task_ids, *inferred_task_ids})
@@ -532,10 +533,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 +567,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 +594,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 +630,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 +3693,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')