From 925cd25d98925182df581c737b0ed2a5c56e5122 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Thu, 6 Aug 2026 20:37:30 +0200 Subject: [PATCH] fix: restore archive downloads and task report context Restore the monthly fleet archive task and routes so generated ZIP downloads work again. Also populate service report DOCX headers from the selected task/work-order context and cover the direct and archived export flows with regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/modules/fleet/tasks.py | 197 ++++++++---------- .../tests/test_work_order_images_endpoint.py | 42 ++++ backend/modules/fleet/urls.py | 10 +- backend/modules/fleet/views.py | 71 ++++++- .../dashboard/TaskCalendarWidget.jsx | 83 +------- 5 files changed, 196 insertions(+), 207 deletions(-) diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 9d017da..5ee21d8 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -836,23 +836,72 @@ def process_work_order_invoice_ocr(invoice_id): @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, +def build_monthly_archive_cached_task(generated_archive_id): + from .models import GeneratedFleetArchive + from .services import NotificationService + from .views import ( + _build_monthly_service_tasks_archive_content, + _build_monthly_work_orders_archive_content, + _notify_monthly_archive_request, ) - 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} + + generated = ( + GeneratedFleetArchive.objects + .select_related('requested_by') + .filter(pk=generated_archive_id, is_active=True) + .first() + ) + if generated is None: + return {"status": "failed", "error": "Generated archive record not found"} + + try: + if generated.archive_type == 'service_tasks': + archive_content = _build_monthly_service_tasks_archive_content( + user=generated.requested_by, + year=generated.year, + month=generated.month, + ) + else: + archive_content = _build_monthly_work_orders_archive_content( + user=generated.requested_by, + year=generated.year, + month=generated.month, + ) + + if not archive_content: + raise ValueError('ZIP arhiva je prazna.') + + filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip" + generated.file.save(filename, ContentFile(archive_content), save=False) + generated.status = 'ready' + generated.error_message = '' + generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) + + if generated.requested_by: + _notify_monthly_archive_request( + user=generated.requested_by, + archive_type=generated.archive_type, + stage='completed', + year=generated.year, + month=generated.month, + generated_archive=generated, + ) + return {"status": "ready", "generated_archive_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: + _notify_monthly_archive_request( + user=generated.requested_by, + archive_type=generated.archive_type, + stage='failed', + year=generated.year, + month=generated.month, + generated_archive=generated, + ) + logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) + return {"status": "failed", "error": str(exc)} @shared_task @@ -875,6 +924,26 @@ def cleanup_expired_generated_archives_task(): return {"deleted": deleted} +@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 @@ -954,98 +1023,4 @@ def build_work_order_pdf_cached_task(generated_pdf_id): }, ) logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc) - return {"status": "failed", "error": str(exc)} - - -@shared_task -def build_monthly_archive_cached_task(generated_archive_id): - from .services import NotificationService - from .views import ( - _build_monthly_service_tasks_archive_content, - _build_monthly_work_orders_archive_content, - _generated_archive_filename_for_user, - ) - - generated = ( - GeneratedFleetArchive.objects - .select_related('requested_by') - .filter(pk=generated_archive_id, is_active=True) - .first() - ) - if generated is None: - return {'error': 'Generated ZIP zapis nije pronađen.'} - - requested_by = generated.requested_by - if requested_by is None: - generated.status = 'failed' - generated.error_message = 'Korisnik koji je zatražio ZIP arhivu nije dostupan.' - generated.save(update_fields=['status', 'error_message', 'updated_at']) - return {'status': 'failed', 'error': generated.error_message} - - try: - if generated.archive_type == 'work_orders': - archive_bytes = _build_monthly_work_orders_archive_content( - user=requested_by, - year=generated.year, - month=generated.month, - ) - else: - archive_bytes = _build_monthly_service_tasks_archive_content( - user=requested_by, - year=generated.year, - month=generated.month, - ) - - filename = generated.filename or _generated_archive_filename_for_user( - requested_by, - year=generated.year, - month=generated.month, - archive_type=generated.archive_type, - ) - generated.file.save(filename, ContentFile(archive_bytes), save=False) - generated.status = 'ready' - generated.error_message = '' - generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) - - NotificationService.create_notification( - recipient=requested_by, - title='ZIP arhiva spremna', - message=f"ZIP arhiva je spremna za preuzimanje ({generated.month:02d}.{generated.year}.).", - level='success', - send_email=False, - metadata={ - 'entity_type': 'fleet_archive', - 'archive_type': generated.archive_type, - 'stage': 'completed', - 'year': generated.year, - 'month': generated.month, - 'generated_archive_id': str(generated.pk), - 'download_url': f"fleet/reports/generated-archives/{generated.pk}/download/", - 'filename': generated.filename or filename, - 'expires_at': generated.expires_at.isoformat() if generated.expires_at else None, - 'section': 'service-records', - }, - ) - return {'status': 'ready', 'generated_archive_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']) - NotificationService.create_notification( - recipient=requested_by, - title='Greška kod ZIP arhive', - message=f"Generiranje ZIP arhive nije uspjelo ({generated.month:02d}.{generated.year}.).", - level='warning', - send_email=False, - metadata={ - 'entity_type': 'fleet_archive', - 'archive_type': generated.archive_type, - 'stage': 'failed', - 'year': generated.year, - 'month': generated.month, - 'generated_archive_id': str(generated.pk), - 'section': 'service-records', - }, - ) - logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) - return {'status': 'failed', 'error': str(exc)} \ No newline at end of file + return {"status": "failed", "error": str(exc)} \ No newline at end of file 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 472224a..ef8ea87 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -60,7 +60,12 @@ class WorkOrderImagesEndpointTests(TestCase): self.vehicle = Vehicle.objects.create( asset_type='crane', registration_number='WO-IMG-001', + make='Liebherr', + model='LTM 1090', crane_serial_number='123456', + superstructure_working_hours=111, + chassis_working_hours=222, + current_mileage=33344, ) self.work_order = WorkOrder.objects.create( vehicle=self.vehicle, @@ -301,6 +306,23 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertIn('24.12.2033', document_xml) self.assertEqual(document_xml.count('w:type="page"'), 1) + def test_task_specific_service_records_docx_uses_dynamic_docx_header_values(self): + response = self.client.get( + f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}" + ) + self.assertEqual(response.status_code, 200, response.content) + + archive = zipfile.ZipFile(BytesIO(response.content)) + header_xml = archive.read('word/header1.xml').decode('utf-8') + self.assertIn('Liebherr LTM 1090', header_xml) + self.assertIn('123456', header_xml) + self.assertIn('111', header_xml) + self.assertIn('222', header_xml) + self.assertIn('33344', header_xml) + self.assertIn('MT150726', header_xml) + self.assertNotIn('MT250226', header_xml) + self.assertNotIn('LTM-1350', header_xml) + def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self): response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') self.assertEqual(response.status_code, 200, response.content) @@ -310,6 +332,26 @@ class WorkOrderImagesEndpointTests(TestCase): names = archive.namelist() self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names) + def test_monthly_service_tasks_archive_docx_uses_dynamic_header_values(self): + response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') + self.assertEqual(response.status_code, 200, response.content) + + archive = zipfile.ZipFile(BytesIO(response.content)) + docx_name = 'MT150726.SN-Test_servisni_zadatak.docx' + self.assertIn(docx_name, archive.namelist()) + + docx_bytes = archive.read(docx_name) + docx_archive = zipfile.ZipFile(BytesIO(docx_bytes)) + header_xml = docx_archive.read('word/header1.xml').decode('utf-8', errors='ignore') + self.assertIn('Liebherr LTM 1090', header_xml) + self.assertIn('123456', header_xml) + self.assertIn('111', header_xml) + self.assertIn('222', header_xml) + self.assertIn('33344', header_xml) + self.assertIn('MT150726', header_xml) + self.assertNotIn('MT250226', header_xml) + self.assertNotIn('LTM-1350', header_xml) + def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self): WorkOrderInvoice.objects.create( work_order=self.work_order, diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index a6739ee..43b18f6 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -3,8 +3,10 @@ from django.urls import path from .views import ( CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, - monthly_servicer_report_docx, monthly_costs_report_docx, monthly_service_tasks_archive, monthly_work_orders_archive, monthly_service_tasks_archive_request, - monthly_work_orders_archive_request, generated_archive_download, MonthlyServicerDayEntryViewSet, + monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet, + monthly_service_tasks_archive, monthly_service_tasks_archive_request, + monthly_work_orders_archive, monthly_work_orders_archive_request, + generated_archive_download, ) router = DefaultRouter() @@ -26,8 +28,8 @@ urlpatterns += [ path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'), path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'), path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'), - path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'), path('reports/monthly-service-tasks-archive-request/', monthly_service_tasks_archive_request, name='monthly-service-tasks-archive-request'), + path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'), path('reports/monthly-work-orders-archive-request/', monthly_work_orders_archive_request, name='monthly-work-orders-archive-request'), - path('reports/generated-archives//download/', generated_archive_download, name='generated-archive-download'), + path('reports/generated-archives//download/', generated_archive_download, name='generated-archive-download'), ] \ No newline at end of file diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 043d593..9d2c693 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -1839,6 +1839,59 @@ def _docx_remove_empty_page_break_paragraphs(document): body.remove(element) +def _build_service_report_docx_context(work_order, related_tasks): + primary_task = related_tasks[0] if len(related_tasks) == 1 else None + context_work_order = getattr(primary_task, 'work_order', None) if primary_task else None + if context_work_order is None: + context_work_order = work_order + + context_vehicle = getattr(primary_task, 'vehicle', None) if primary_task else None + if context_vehicle is None: + context_vehicle = getattr(context_work_order, 'vehicle', None) or work_order.vehicle + + model_parts = [ + str(getattr(context_vehicle, 'make', '') or '').strip(), + str(getattr(context_vehicle, 'model', '') or '').strip(), + ] + model_label = " ".join(part for part in model_parts if part) or '-' + return { + 'work_order': context_work_order, + 'vehicle': context_vehicle, + 'client_name': getattr(getattr(context_vehicle, 'client', None), 'name', None) or '-', + 'location': getattr(context_work_order, 'location', None) or '-', + 'servicer_name': _user_display_name(getattr(context_work_order, 'creator', None)) or '-', + 'servicer_vehicle_make_model': getattr(context_work_order, 'servicer_vehicle_make_model', None) or '-', + 'servicer_vehicle_registration': getattr(context_work_order, 'servicer_vehicle_registration', None) or '-', + 'completion_label': "Da ☒ Ne ☐" if getattr(context_work_order, 'status', None) == 'closed' else "Da ☐ Ne ☒", + 'model': model_label, + 'serial': str(getattr(context_vehicle, 'crane_serial_number', None) or '-'), + 'upgrade_hours': str(getattr(context_vehicle, 'superstructure_working_hours', None) or '-'), + 'chassis_hours': str(getattr(context_vehicle, 'chassis_working_hours', None) or '-'), + 'mileage': str(getattr(context_vehicle, 'current_mileage', None) or '-'), + 'work_order_display_code': _work_order_display_code(context_work_order), + } + + +def _populate_service_report_docx_header(document, context): + for section in getattr(document, 'sections', []): + header = getattr(section, 'header', None) + if header is None: + continue + header_tables = list(getattr(header, 'tables', [])) + if len(header_tables) >= 2: + table_meta = header_tables[0] + _set_docx_cell_text(table_meta, 1, 0, context['model']) + _set_docx_cell_text(table_meta, 1, 2, context['serial']) + _set_docx_cell_text(table_meta, 1, 4, context['upgrade_hours']) + _set_docx_cell_text(table_meta, 1, 5, context['chassis_hours']) + _set_docx_cell_text(table_meta, 1, 6, context['mileage']) + _set_docx_cell_text(table_meta, 1, 8, context['work_order_display_code']) + + table_title = header_tables[1] + _set_docx_cell_text(table_title, 0, 0, context['client_name']) + _set_docx_cell_text(table_title, 0, 1, 'Izvještaj servisera') + + def _build_work_order_docx_bytes(work_order): vehicle = work_order.vehicle creator = work_order.creator @@ -1893,15 +1946,13 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) from docx.shared import Cm doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME) - vehicle = work_order.vehicle - client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-' - servicer_name = _user_display_name(work_order.creator) or '-' - completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒" if related_tasks is None: related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table')) else: related_tasks = list(related_tasks) + context = _build_service_report_docx_context(work_order, related_tasks) + _populate_service_report_docx_header(doc, context) normalized_rows = [] for task in related_tasks: table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) @@ -1961,17 +2012,17 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) if len(doc.tables) >= 3: info_table = doc.tables[0] - _set_docx_cell_text(info_table, 1, 0, client_name) - _set_docx_cell_text(info_table, 1, 1, work_order.location or '-') - _set_docx_cell_text(info_table, 3, 0, servicer_name) + _set_docx_cell_text(info_table, 1, 0, context['client_name']) + _set_docx_cell_text(info_table, 1, 1, context['location']) + _set_docx_cell_text(info_table, 3, 0, context['servicer_name']) if len(info_table.rows[3].cells) > 1: _set_docx_cell_text(info_table, 3, 1, '-') transport_table = doc.tables[1] - _set_docx_cell_text(transport_table, 1, 0, work_order.servicer_vehicle_make_model or '-') - _set_docx_cell_text(transport_table, 1, 1, work_order.servicer_vehicle_registration or '-') + _set_docx_cell_text(transport_table, 1, 0, context['servicer_vehicle_make_model']) + _set_docx_cell_text(transport_table, 1, 1, context['servicer_vehicle_registration']) _set_docx_cell_text(transport_table, 1, 2, '-') - _set_docx_cell_text(transport_table, 1, 4, completion_label) + _set_docx_cell_text(transport_table, 1, 4, context['completion_label']) all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])] summary_table = doc.tables[2] diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 9c2b63b..58d7a1c 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -1,10 +1,8 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { getStatusLabel } from '../../stores/taskStore'; import { - downloadMonthlyServiceTasksArchive, downloadMonthlyCostsReport, downloadMonthlyServiserReport, - downloadMonthlyWorkOrdersArchive, fetchMonthlyCostInvoices, fetchMonthlyServicerEntries, upsertMonthlyServicerEntry, @@ -91,9 +89,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders const [manualEntryDraft, setManualEntryDraft] = useState(null); const [manualEntryError, setManualEntryError] = useState(''); const [savingManualEntry, setSavingManualEntry] = useState(false); - const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false); - const [downloadingAllTasks, setDownloadingAllTasks] = useState(false); - const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false); const reportOpen = reportType !== null; const panelWidth = reportOpen ? 920 : 300; @@ -346,9 +341,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders function toggleReport(type) { setReportType((prev) => (prev === type ? null : type)); setManualEntryTarget(null); - if (type !== 'servicer') { - setBulkDownloadOpen(false); - } } async function handleDownload() { @@ -365,28 +357,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders } } - async function handleDownloadAllTasksArchive() { - if (downloadingAllTasks) return; - setDownloadingAllTasks(true); - try { - await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); - } finally { - setDownloadingAllTasks(false); - } - } - - async function handleDownloadAllWorkOrdersArchive() { - if (downloadingAllWorkOrders) return; - setDownloadingAllWorkOrders(true); - try { - await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); - } finally { - setDownloadingAllWorkOrders(false); - } - } - function handleServicerRowClick(row) { if (!row?.clickable) return; setManualEntryTarget(row); @@ -661,17 +631,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders disabled={downloading} className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60" > - {downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'} + {downloading ? 'Preuzimanje...' : 'Preuzmi DOCX'} - {reportType === 'servicer' && ( - - )}

@@ -864,48 +825,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders )} - - {bulkDownloadOpen && reportType === 'servicer' && ( -

-
-
-
-

Preuzmi sve

-

- {MONTH_NAMES[viewMonth]} {viewYear} -

-
- -
- -
- - -
-
-
- )} )}