diff --git a/backend/modules/fleet/migrations/0035_generatedfleetarchive.py b/backend/modules/fleet/migrations/0035_generatedfleetarchive.py new file mode 100644 index 0000000..3660c37 --- /dev/null +++ b/backend/modules/fleet/migrations/0035_generatedfleetarchive.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import modules.fleet.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0034_monthlyservicerdayentry'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedFleetArchive', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('archive_type', models.CharField(choices=[('service_tasks', 'Pojedinačni servisni taskovi'), ('work_orders', 'Putni nalozi i računi')], db_index=True, max_length=32, verbose_name='Tip ZIP arhive')), + ('year', models.PositiveSmallIntegerField(db_index=True, verbose_name='Godina')), + ('month', models.PositiveSmallIntegerField(db_index=True, verbose_name='Mjesec')), + ('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')), + ('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_fleet_archive_upload_to, verbose_name='ZIP datoteka')), + ('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')), + ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')), + ('error_message', models.TextField(blank=True, verbose_name='Poruka greške')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_fleet_archives', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')), + ], + options={ + 'verbose_name': 'Generirana ZIP arhiva', + 'verbose_name_plural': 'Generirane ZIP arhive', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index d3eca2d..4829f51 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -65,6 +65,16 @@ def _generated_work_order_pdf_upload_to(instance, filename): return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}" +def _generated_fleet_archive_upload_to(instance, filename): + requester = 'unknown' + try: + requester = str(instance.requested_by_id or 'unknown') + except Exception: + requester = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/generated_archives/{requester}/{unique_name}" + + class VehicleServicePhoto(BaseModel): """ Fotografije kvarova / radova povezane s VehicleServiceRecord. @@ -480,6 +490,59 @@ class GeneratedWorkOrderPdf(BaseModel): return f"{self.pdf_type}:{self.work_order_id}:{self.status}" +class GeneratedFleetArchive(BaseModel): + ARCHIVE_TYPE_CHOICES = [ + ('service_tasks', _("Pojedinačni servisni taskovi")), + ('work_orders', _("Putni nalozi i računi")), + ] + STATUS_CHOICES = [ + ('pending', _("U obradi")), + ('ready', _("Spremno")), + ('failed', _("Neuspješno")), + ] + + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='requested_generated_fleet_archives', + verbose_name=_("Zatražio"), + ) + archive_type = models.CharField( + max_length=32, + choices=ARCHIVE_TYPE_CHOICES, + db_index=True, + verbose_name=_("Tip ZIP arhive"), + ) + year = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Godina")) + month = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Mjesec")) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default='pending', + db_index=True, + verbose_name=_("Status"), + ) + file = models.FileField( + upload_to=_generated_fleet_archive_upload_to, + null=True, + blank=True, + verbose_name=_("ZIP datoteka"), + ) + filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke")) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do")) + error_message = models.TextField(blank=True, verbose_name=_("Poruka greške")) + + class Meta: + verbose_name = _("Generirana ZIP arhiva") + verbose_name_plural = _("Generirane ZIP arhive") + ordering = ['-created_at'] + + def __str__(self): + return f"{self.archive_type}:{self.year}-{self.month:02d}:{self.status}" + + class VehicleServiceRecord(BaseModel): """ Zapisi o servisima/popravcima na vozilu. diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 32ec4a1..9d017da 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -27,7 +27,7 @@ from pytesseract import TesseractNotFoundError from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas -from .models import VehicleNotification, GeneratedWorkOrderPdf +from .models import VehicleNotification, GeneratedWorkOrderPdf, GeneratedFleetArchive from .pdf_layout import register_unicode_fonts, draw_standard_header_footer from .email_utils import append_user_signature @@ -855,6 +855,26 @@ def cleanup_expired_generated_pdfs_task(): return {"deleted": deleted} +@shared_task +def cleanup_expired_generated_archives_task(): + now = timezone.now() + expired = GeneratedFleetArchive.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 = 'ZIP arhiva je istekla.' + 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 @@ -934,4 +954,98 @@ 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)} \ No newline at end of file + 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 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 67c79aa..c6fe9ba 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -8,12 +8,20 @@ import uuid from io import BytesIO from datetime import date, timedelta import zipfile -from pathlib import Path -from django.conf import settings from PIL import Image -from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf +from modules.fleet.models import ( + Vehicle, + WorkOrder, + WorkOrderInvoice, + VehicleServiceRecord, + VehicleServicePhoto, + GeneratedWorkOrderPdf, + GeneratedFleetArchive, + VehicleNotification, +) from modules.task_management.models import Task +from modules.fleet.tasks import build_monthly_archive_cached_task def create_test_image(filename='test.jpg', size=(40, 40), color='red'): @@ -310,8 +318,6 @@ class WorkOrderImagesEndpointTests(TestCase): image=create_test_pdf('racun-prosinac.pdf'), created_by=self.user, ) - - before_files = set((Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives').glob('*.zip')) response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12') self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response['Content-Type'], 'application/zip') @@ -321,7 +327,83 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertIn('MT150726.work-order.pdf', names) self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names) - generated_archives_dir = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' - self.assertTrue(generated_archives_dir.exists()) - after_files = set(generated_archives_dir.glob('*.zip')) - self.assertTrue(after_files - before_files) + def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self): + response = self.client.post( + '/api/fleet/reports/monthly-service-tasks-archive-request/', + data={'year': 2033, 'month': 12}, + format='json', + ) + self.assertIn(response.status_code, [200, 202], response.content) + payload = response.json() + self.assertIn('generated_archive_id', payload) + + generated = GeneratedFleetArchive.objects.filter( + pk=payload['generated_archive_id'], + requested_by=self.user, + archive_type='service_tasks', + ).first() + self.assertIsNotNone(generated) + if generated.status != 'ready': + build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get() + generated.refresh_from_db() + self.assertEqual(generated.status, 'ready') + + download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/") + self.assertEqual(download_response.status_code, 200) + self.assertEqual(download_response['Content-Type'], 'application/zip') + archive_bytes = b''.join(download_response.streaming_content) + + archive = zipfile.ZipFile(BytesIO(archive_bytes)) + self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', archive.namelist()) + + notifications = VehicleNotification.objects.filter(recipient=self.user).order_by('created_at') + self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').exists()) + self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').exists()) + + def test_monthly_work_orders_archive_request_creates_ready_download_with_notification(self): + WorkOrderInvoice.objects.create( + work_order=self.work_order, + naziv_racuna='Prosinac račun', + datum='2033-12-10', + image=create_test_pdf('racun-prosinac.pdf'), + created_by=self.user, + ) + + response = self.client.post( + '/api/fleet/reports/monthly-work-orders-archive-request/', + data={'year': 2033, 'month': 12}, + format='json', + ) + self.assertIn(response.status_code, [200, 202], response.content) + payload = response.json() + self.assertIn('generated_archive_id', payload) + + generated = GeneratedFleetArchive.objects.filter( + pk=payload['generated_archive_id'], + requested_by=self.user, + archive_type='work_orders', + ).first() + self.assertIsNotNone(generated) + if generated.status != 'ready': + build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get() + generated.refresh_from_db() + self.assertEqual(generated.status, 'ready') + + download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/") + self.assertEqual(download_response.status_code, 200) + self.assertEqual(download_response['Content-Type'], 'application/zip') + archive_bytes = b''.join(download_response.streaming_content) + + archive = zipfile.ZipFile(BytesIO(archive_bytes)) + names = archive.namelist() + self.assertIn('MT150726.work-order.pdf', names) + self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names) + + self.assertTrue( + VehicleNotification.objects.filter( + recipient=self.user, + metadata__entity_type='fleet_archive', + metadata__archive_type='work_orders', + metadata__stage='completed', + ).exists() + ) diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index 6f55b02..a6739ee 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -3,7 +3,8 @@ 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, MonthlyServicerDayEntryViewSet, + 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, ) router = DefaultRouter() @@ -26,4 +27,7 @@ urlpatterns += [ 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-request/', monthly_work_orders_archive_request, name='monthly-work-orders-archive-request'), + 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 c9f428a..b7b83b7 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -50,6 +50,7 @@ from .models import ( WorkOrder, WorkOrderPhoto, GeneratedWorkOrderPdf, + GeneratedFleetArchive, WorkOrderInvoice, WorkOrderAdditionalCostsTable, VehicleServiceRecord, @@ -80,6 +81,8 @@ from .services import ( from .tasks import ( build_work_order_invoices_pdf_task, build_work_order_pdf_cached_task, + build_monthly_archive_cached_task, + cleanup_expired_generated_archives_task, cleanup_expired_generated_pdfs_task, process_work_order_invoice_ocr, send_work_order_email_bundle_task, @@ -2678,32 +2681,10 @@ def _monthly_archive_prefix_for_user(user): return 'MT' -def _generated_archives_dir(): - root = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' - root.mkdir(parents=True, exist_ok=True) - return root - - -def _cleanup_expired_generated_archives(): - root = _generated_archives_dir() - threshold = timezone.now() - timedelta(days=GENERATED_ARCHIVE_TTL_DAYS) - threshold_ts = threshold.timestamp() - for candidate in root.glob('*.zip'): - try: - if candidate.stat().st_mtime <= threshold_ts: - candidate.unlink(missing_ok=True) - except OSError: - continue - - -def _persist_generated_archive(filename, content): - root = _generated_archives_dir() - timestamp = timezone.now().strftime('%Y%m%d%H%M%S') - stored_name = f"{timestamp}-{Path(filename).name}" - target = root / stored_name - with target.open('wb') as handle: - handle.write(content) - return target +def _generated_archive_filename_for_user(user, *, year, month, archive_type): + prefix = _monthly_archive_prefix_for_user(user) + suffix = 'SN' if archive_type == 'service_tasks' else 'PN' + return f"{prefix}-{month:02d}-{year}-{suffix}.zip" def _unique_zip_entry_name(entry_name, used_names): @@ -2721,24 +2702,33 @@ def _unique_zip_entry_name(entry_name, used_names): return candidate -@api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) -def monthly_service_tasks_archive(request): +def _parse_year_month_params(request): from datetime import date as _dt_date - from modules.task_management.models import Task + + raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None + raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None + if raw_year in (None, ''): + raw_year = request.query_params.get('year', _dt_date.today().year) + if raw_month in (None, ''): + raw_month = request.query_params.get('month', _dt_date.today().month) try: - year = int(request.query_params.get('year', _dt_date.today().year)) - month = int(request.query_params.get('month', _dt_date.today().month)) + year = int(raw_year) + month = int(raw_month) if not (1 <= month <= 12): raise ValueError() except (ValueError, TypeError): - return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) + raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'}) + return year, month + + +def _build_monthly_service_tasks_archive_content(*, user, year, month): + from modules.task_management.models import Task tasks = list( Task.objects .filter( - assigned_to=request.user, + assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, @@ -2749,7 +2739,7 @@ def monthly_service_tasks_archive(request): .order_by('scheduled_date', 'created_at') ) if not tasks: - return Response({'detail': 'Nema servisnih taskova za odabrani mjesec.'}, status=404) + raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'}) used_names = set() archive_buffer = BytesIO() @@ -2765,36 +2755,17 @@ def monthly_service_tasks_archive(request): archive_content = archive_buffer.getvalue() if not archive_content: - return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400) - - prefix = _monthly_archive_prefix_for_user(request.user) - archive_filename = f"{prefix}-{month:02d}-{year}-SN.zip" - _cleanup_expired_generated_archives() - _persist_generated_archive(archive_filename, archive_content) - - response = HttpResponse(archive_content, content_type='application/zip') - response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' - return response + raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) + return archive_content -@api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) -def monthly_work_orders_archive(request): - from datetime import date as _dt_date +def _build_monthly_work_orders_archive_content(*, user, year, month): from modules.task_management.models import Task - try: - year = int(request.query_params.get('year', _dt_date.today().year)) - month = int(request.query_params.get('month', _dt_date.today().month)) - if not (1 <= month <= 12): - raise ValueError() - except (ValueError, TypeError): - return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) - monthly_tasks = ( Task.objects .filter( - assigned_to=request.user, + assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, @@ -2804,18 +2775,27 @@ def monthly_work_orders_archive(request): .select_related('work_order') .order_by('scheduled_date', 'created_at') ) - work_order_ids = [task.work_order_id for task in monthly_tasks if task.work_order_id] - if not work_order_ids: - return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404) + ordered_work_order_ids = [] + seen_work_order_ids = set() + for task in monthly_tasks: + if not task.work_order_id or task.work_order_id in seen_work_order_ids: + continue + seen_work_order_ids.add(task.work_order_id) + ordered_work_order_ids.append(task.work_order_id) + if not ordered_work_order_ids: + raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) - work_orders = list( - WorkOrder.objects - .filter(id__in=work_order_ids, is_active=True) - .select_related('vehicle', 'vehicle__client', 'creator') - .distinct() - ) + work_orders_map = { + work_order.id: work_order + for work_order in ( + WorkOrder.objects + .filter(id__in=ordered_work_order_ids, is_active=True) + .select_related('vehicle', 'vehicle__client', 'creator') + ) + } + work_orders = [work_orders_map[work_order_id] for work_order_id in ordered_work_order_ids if work_order_id in work_orders_map] if not work_orders: - return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404) + raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) used_names = set() archive_buffer = BytesIO() @@ -2837,18 +2817,264 @@ def monthly_work_orders_archive(request): archive_content = archive_buffer.getvalue() if not archive_content: - return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400) + raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) + return archive_content - prefix = _monthly_archive_prefix_for_user(request.user) - archive_filename = f"{prefix}-{month:02d}-{year}-PN.zip" - _cleanup_expired_generated_archives() - _persist_generated_archive(archive_filename, archive_content) +def _cleanup_expired_generated_archive_records(): + now = timezone.now() + expired = GeneratedFleetArchive.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 = 'ZIP arhiva je istekla.' + item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + + +def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None): + if user is None: + return + month_label = f"{month:02d}.{year}." + if archive_type == 'service_tasks': + archive_label = 'pojedinačnih servisnih taskova' + else: + archive_label = 'putnih naloga i računa' + + if stage == 'requested': + title = "ZIP arhiva u pripremi" + message = f"Zaprimljen je zahtjev za generiranje ZIP arhive {archive_label} za {month_label}" + level = 'info' + elif stage == 'failed': + title = "Greška kod ZIP arhive" + message = f"Generiranje ZIP arhive {archive_label} nije uspjelo za {month_label}" + level = 'warning' + else: + title = "ZIP arhiva spremna" + message = f"ZIP arhiva {archive_label} je spremna za preuzimanje ({month_label})" + level = 'success' + + metadata = { + 'entity_type': 'fleet_archive', + 'archive_type': archive_type, + 'stage': stage, + 'year': year, + 'month': month, + 'section': 'service-records', + } + if generated_archive and generated_archive.pk: + metadata['generated_archive_id'] = str(generated_archive.pk) + metadata['download_url'] = f"fleet/reports/generated-archives/{generated_archive.pk}/download/" + metadata['filename'] = generated_archive.filename or _generated_archive_filename_for_user( + user, + year=year, + month=month, + archive_type=archive_type, + ) + if generated_archive.expires_at: + metadata['expires_at'] = generated_archive.expires_at.isoformat() + + NotificationService.create_notification( + recipient=user, + title=title, + message=message, + level=level, + send_email=False, + metadata=metadata, + ) + + +def _get_cached_generated_archive(*, user, archive_type, year, month): + return ( + GeneratedFleetArchive.objects + .filter( + is_active=True, + requested_by=user, + archive_type=archive_type, + year=year, + month=month, + status='ready', + expires_at__gt=timezone.now(), + ) + .exclude(file='') + .exclude(file__isnull=True) + .order_by('-created_at') + .first() + ) + + +def _request_monthly_archive_generation(*, request, archive_type): + year, month = _parse_year_month_params(request) + _cleanup_expired_generated_archive_records() + + cached = _get_cached_generated_archive( + user=request.user, + archive_type=archive_type, + year=year, + month=month, + ) + if cached: + _notify_monthly_archive_request( + user=request.user, + archive_type=archive_type, + stage='completed', + year=year, + month=month, + generated_archive=cached, + ) + return { + 'status': 'ready', + 'generated_archive_id': str(cached.pk), + 'download_url': f"fleet/reports/generated-archives/{cached.pk}/download/", + 'filename': cached.filename, + 'expires_at': cached.expires_at.isoformat() if cached.expires_at else None, + } + + existing_pending = ( + GeneratedFleetArchive.objects + .filter( + is_active=True, + requested_by=request.user, + archive_type=archive_type, + year=year, + month=month, + status='pending', + ) + .order_by('-created_at') + .first() + ) + if existing_pending: + return { + 'status': 'processing', + 'generated_archive_id': str(existing_pending.pk), + } + + generated_archive = GeneratedFleetArchive.objects.create( + requested_by=request.user, + archive_type=archive_type, + year=year, + month=month, + status='pending', + filename=_generated_archive_filename_for_user( + request.user, + year=year, + month=month, + archive_type=archive_type, + ), + expires_at=timezone.now() + timedelta(days=GENERATED_ARCHIVE_TTL_DAYS), + ) + _notify_monthly_archive_request( + user=request.user, + archive_type=archive_type, + stage='requested', + year=year, + month=month, + ) + + try: + build_monthly_archive_cached_task.delay(str(generated_archive.pk)) + cleanup_expired_generated_archives_task.delay() + except KombuOperationalError: + build_monthly_archive_cached_task.apply(args=[str(generated_archive.pk)]).get() + cleanup_expired_generated_archives_task.apply().get() + + return { + 'status': 'processing', + 'generated_archive_id': str(generated_archive.pk), + } + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_service_tasks_archive(request): + year, month = _parse_year_month_params(request) + archive_content = _build_monthly_service_tasks_archive_content( + user=request.user, + year=year, + month=month, + ) + archive_filename = _generated_archive_filename_for_user( + request.user, + year=year, + month=month, + archive_type='service_tasks', + ) response = HttpResponse(archive_content, content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' return response +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_service_tasks_archive_request(request): + payload = _request_monthly_archive_generation(request=request, archive_type='service_tasks') + return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_work_orders_archive(request): + year, month = _parse_year_month_params(request) + archive_content = _build_monthly_work_orders_archive_content( + user=request.user, + year=year, + month=month, + ) + archive_filename = _generated_archive_filename_for_user( + request.user, + year=year, + month=month, + archive_type='work_orders', + ) + response = HttpResponse(archive_content, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' + return response + + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_work_orders_archive_request(request): + payload = _request_monthly_archive_generation(request=request, archive_type='work_orders') + return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def generated_archive_download(request, archive_id): + _cleanup_expired_generated_archive_records() + generated_archive = ( + GeneratedFleetArchive.objects + .filter( + is_active=True, + requested_by=request.user, + pk=archive_id, + status='ready', + expires_at__gt=timezone.now(), + ) + .exclude(file='') + .exclude(file__isnull=True) + .first() + ) + if generated_archive is None: + raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'}) + + generated_archive.file.open('rb') + filename = generated_archive.filename or _generated_archive_filename_for_user( + request.user, + year=generated_archive.year, + month=generated_archive.month, + archive_type=generated_archive.archive_type, + ) + response = FileResponse(generated_archive.file, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['Cache-Control'] = 'private, max-age=3600' + return response + + @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def pusher_auth(request): diff --git a/frontend/src/components/notifications/NotificationDetailModal.jsx b/frontend/src/components/notifications/NotificationDetailModal.jsx index 676a556..1ca8b8a 100644 --- a/frontend/src/components/notifications/NotificationDetailModal.jsx +++ b/frontend/src/components/notifications/NotificationDetailModal.jsx @@ -1,6 +1,7 @@ import { useMemo } from 'preact/hooks'; import ModalShell from '../ui/ModalShell'; import { + downloadGeneratedArchiveByUrl, downloadGeneratedPdfByUrl, downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, @@ -21,6 +22,7 @@ function formatTimestamp(value) { } function detectNotificationKind(notification) { + if (notification?.metadata?.entity_type === 'fleet_archive') return 'fleet_archive'; const title = String(notification?.title || '').toLowerCase(); if (title.includes('servisni kontekst')) return 'service_context'; if (title.includes('pdf')) return 'work_order_pdf'; @@ -79,6 +81,28 @@ function getEntityAction(notification, meta) { }, }; } + if (metadata.entity_type === 'fleet_archive') { + const archiveType = metadata.archive_type === 'work_orders' ? 'work_order' : 'service_tasks'; + if (metadata.stage === 'completed' && metadata.download_url) { + return { + label: archiveType === 'work_order' + ? 'Preuzmi ZIP putnih naloga + računa' + : 'Preuzmi ZIP servisnih taskova', + run() { + return downloadGeneratedArchiveByUrl( + metadata.download_url, + metadata.filename || `MT-${String(metadata.month || '').padStart(2, '0')}-${metadata.year || ''}-${archiveType === 'work_order' ? 'PN' : 'SN'}.zip`, + ); + }, + }; + } + return { + label: 'Otvori servisne zapise', + run() { + window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: 'service-records' })); + }, + }; + } if (metadata.entity_type === 'work_order' && metadata.work_order_id) { return { label: 'Otvori detalje naloga', @@ -125,6 +149,13 @@ function getNotificationTypeMeta(kind) { recommendation: 'Možete odmah preuzeti generirani PDF dokument.', }; } + if (kind === 'fleet_archive') { + return { + category: 'ZIP arhive', + section: 'service-records', + recommendation: 'Pričekajte završetak generiranja, zatim preuzmite ZIP iz notifikacije.', + }; + } if (kind.startsWith('work_order')) { return { category: 'Putni nalozi', @@ -168,6 +199,11 @@ export default function NotificationDetailModal({ open, notification, onClose }) notification?.metadata?.stage === 'completed' && notification?.metadata?.work_order_id ); + const showArchiveLink = Boolean( + notification?.metadata?.entity_type === 'fleet_archive' && + notification?.metadata?.stage === 'completed' && + notification?.metadata?.download_url + ); const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs) ? notification.metadata.cached_pdfs : []; @@ -212,7 +248,7 @@ export default function NotificationDetailModal({ open, notification, onClose })

{notification.title || 'Obavijest'}

{notification.message || '-'}

- {showPdfLink && ( + {(showPdfLink || showArchiveLink) && (