feat: add async monthly ZIP generation with notifications
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

Implement background generation for monthly SN/PN ZIP archives and expose completion through in-app notifications.

- add GeneratedFleetArchive persistence model with expiry metadata
- add archive request/download endpoints and Celery background tasks
- emit notification stages for requested/completed/failed archive jobs
- update calendar bulk download actions to trigger async requests
- add notification modal actions to download generated ZIP files
- extend backend tests for async archive request and download flows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-06 10:59:19 +02:00
parent 594881f6cd
commit 456c9e4c3e
8 changed files with 693 additions and 100 deletions

View File

@@ -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'],
},
),
]

View File

@@ -65,6 +65,16 @@ def _generated_work_order_pdf_upload_to(instance, filename):
return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}" 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): class VehicleServicePhoto(BaseModel):
""" """
Fotografije kvarova / radova povezane s VehicleServiceRecord. Fotografije kvarova / radova povezane s VehicleServiceRecord.
@@ -480,6 +490,59 @@ class GeneratedWorkOrderPdf(BaseModel):
return f"{self.pdf_type}:{self.work_order_id}:{self.status}" 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): class VehicleServiceRecord(BaseModel):
""" """
Zapisi o servisima/popravcima na vozilu. Zapisi o servisima/popravcima na vozilu.

View File

@@ -27,7 +27,7 @@ from pytesseract import TesseractNotFoundError
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas 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 .pdf_layout import register_unicode_fonts, draw_standard_header_footer
from .email_utils import append_user_signature from .email_utils import append_user_signature
@@ -855,6 +855,26 @@ def cleanup_expired_generated_pdfs_task():
return {"deleted": deleted} 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 @shared_task
def build_work_order_pdf_cached_task(generated_pdf_id): def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf 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) logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
return {"status": "failed", "error": str(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)}

View File

@@ -8,12 +8,20 @@ import uuid
from io import BytesIO from io import BytesIO
from datetime import date, timedelta from datetime import date, timedelta
import zipfile import zipfile
from pathlib import Path
from django.conf import settings
from PIL import Image 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.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'): 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'), image=create_test_pdf('racun-prosinac.pdf'),
created_by=self.user, 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') 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.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/zip') self.assertEqual(response['Content-Type'], 'application/zip')
@@ -321,7 +327,83 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('MT150726.work-order.pdf', names) self.assertIn('MT150726.work-order.pdf', names)
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names) self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
generated_archives_dir = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
self.assertTrue(generated_archives_dir.exists()) response = self.client.post(
after_files = set(generated_archives_dir.glob('*.zip')) '/api/fleet/reports/monthly-service-tasks-archive-request/',
self.assertTrue(after_files - before_files) 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()
)

View File

@@ -3,7 +3,8 @@ from django.urls import path
from .views import ( from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, 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() router = DefaultRouter()
@@ -26,4 +27,7 @@ urlpatterns += [
path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-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-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-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/<uuid:archive_id>/download/', generated_archive_download, name='generated-archive-download'),
] ]

View File

@@ -50,6 +50,7 @@ from .models import (
WorkOrder, WorkOrder,
WorkOrderPhoto, WorkOrderPhoto,
GeneratedWorkOrderPdf, GeneratedWorkOrderPdf,
GeneratedFleetArchive,
WorkOrderInvoice, WorkOrderInvoice,
WorkOrderAdditionalCostsTable, WorkOrderAdditionalCostsTable,
VehicleServiceRecord, VehicleServiceRecord,
@@ -80,6 +81,8 @@ from .services import (
from .tasks import ( from .tasks import (
build_work_order_invoices_pdf_task, build_work_order_invoices_pdf_task,
build_work_order_pdf_cached_task, build_work_order_pdf_cached_task,
build_monthly_archive_cached_task,
cleanup_expired_generated_archives_task,
cleanup_expired_generated_pdfs_task, cleanup_expired_generated_pdfs_task,
process_work_order_invoice_ocr, process_work_order_invoice_ocr,
send_work_order_email_bundle_task, send_work_order_email_bundle_task,
@@ -2678,32 +2681,10 @@ def _monthly_archive_prefix_for_user(user):
return 'MT' return 'MT'
def _generated_archives_dir(): def _generated_archive_filename_for_user(user, *, year, month, archive_type):
root = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' prefix = _monthly_archive_prefix_for_user(user)
root.mkdir(parents=True, exist_ok=True) suffix = 'SN' if archive_type == 'service_tasks' else 'PN'
return root return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
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 _unique_zip_entry_name(entry_name, used_names): def _unique_zip_entry_name(entry_name, used_names):
@@ -2721,24 +2702,33 @@ def _unique_zip_entry_name(entry_name, used_names):
return candidate return candidate
@api_view(['GET']) def _parse_year_month_params(request):
@permission_classes([permissions.IsAuthenticated])
def monthly_service_tasks_archive(request):
from datetime import date as _dt_date 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: try:
year = int(request.query_params.get('year', _dt_date.today().year)) year = int(raw_year)
month = int(request.query_params.get('month', _dt_date.today().month)) month = int(raw_month)
if not (1 <= month <= 12): if not (1 <= month <= 12):
raise ValueError() raise ValueError()
except (ValueError, TypeError): 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( tasks = list(
Task.objects Task.objects
.filter( .filter(
assigned_to=request.user, assigned_to=user,
is_active=True, is_active=True,
scheduled_date__year=year, scheduled_date__year=year,
scheduled_date__month=month, scheduled_date__month=month,
@@ -2749,7 +2739,7 @@ def monthly_service_tasks_archive(request):
.order_by('scheduled_date', 'created_at') .order_by('scheduled_date', 'created_at')
) )
if not tasks: 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() used_names = set()
archive_buffer = BytesIO() archive_buffer = BytesIO()
@@ -2765,36 +2755,17 @@ def monthly_service_tasks_archive(request):
archive_content = archive_buffer.getvalue() archive_content = archive_buffer.getvalue()
if not archive_content: 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}-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
@api_view(['GET']) def _build_monthly_work_orders_archive_content(*, user, year, month):
@permission_classes([permissions.IsAuthenticated])
def monthly_work_orders_archive(request):
from datetime import date as _dt_date
from modules.task_management.models import Task 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 = ( monthly_tasks = (
Task.objects Task.objects
.filter( .filter(
assigned_to=request.user, assigned_to=user,
is_active=True, is_active=True,
scheduled_date__year=year, scheduled_date__year=year,
scheduled_date__month=month, scheduled_date__month=month,
@@ -2804,18 +2775,27 @@ def monthly_work_orders_archive(request):
.select_related('work_order') .select_related('work_order')
.order_by('scheduled_date', 'created_at') .order_by('scheduled_date', 'created_at')
) )
work_order_ids = [task.work_order_id for task in monthly_tasks if task.work_order_id] ordered_work_order_ids = []
if not work_order_ids: seen_work_order_ids = set()
return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404) 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( work_orders_map = {
WorkOrder.objects work_order.id: work_order
.filter(id__in=work_order_ids, is_active=True) for work_order in (
.select_related('vehicle', 'vehicle__client', 'creator') WorkOrder.objects
.distinct() .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: 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() used_names = set()
archive_buffer = BytesIO() archive_buffer = BytesIO()
@@ -2837,18 +2817,264 @@ def monthly_work_orders_archive(request):
archive_content = archive_buffer.getvalue() archive_content = archive_buffer.getvalue()
if not archive_content: 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 = HttpResponse(archive_content, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' response['Content-Disposition'] = f'attachment; filename="{archive_filename}"'
return response 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']) @api_view(['POST'])
@permission_classes([permissions.IsAuthenticated]) @permission_classes([permissions.IsAuthenticated])
def pusher_auth(request): def pusher_auth(request):

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell'; import ModalShell from '../ui/ModalShell';
import { import {
downloadGeneratedArchiveByUrl,
downloadGeneratedPdfByUrl, downloadGeneratedPdfByUrl,
downloadWorkOrderInvoicesPdf, downloadWorkOrderInvoicesPdf,
downloadWorkOrderPdf, downloadWorkOrderPdf,
@@ -21,6 +22,7 @@ function formatTimestamp(value) {
} }
function detectNotificationKind(notification) { function detectNotificationKind(notification) {
if (notification?.metadata?.entity_type === 'fleet_archive') return 'fleet_archive';
const title = String(notification?.title || '').toLowerCase(); const title = String(notification?.title || '').toLowerCase();
if (title.includes('servisni kontekst')) return 'service_context'; if (title.includes('servisni kontekst')) return 'service_context';
if (title.includes('pdf')) return 'work_order_pdf'; 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) { if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
return { return {
label: 'Otvori detalje naloga', label: 'Otvori detalje naloga',
@@ -125,6 +149,13 @@ function getNotificationTypeMeta(kind) {
recommendation: 'Možete odmah preuzeti generirani PDF dokument.', 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')) { if (kind.startsWith('work_order')) {
return { return {
category: 'Putni nalozi', category: 'Putni nalozi',
@@ -168,6 +199,11 @@ export default function NotificationDetailModal({ open, notification, onClose })
notification?.metadata?.stage === 'completed' && notification?.metadata?.stage === 'completed' &&
notification?.metadata?.work_order_id 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) const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs)
? notification.metadata.cached_pdfs ? notification.metadata.cached_pdfs
: []; : [];
@@ -212,7 +248,7 @@ export default function NotificationDetailModal({ open, notification, onClose })
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3"> <div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p> <p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p> <p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
{showPdfLink && ( {(showPdfLink || showArchiveLink) && (
<button <button
type="button" type="button"
onClick={() => action.run()} onClick={() => action.run()}

View File

@@ -831,6 +831,13 @@ function _schedulePdfNotificationPolling() {
}); });
} }
function _scheduleArchiveNotificationPolling() {
if (!isBrowser()) return;
[5000, 20000, 60000].forEach((delay) => {
setTimeout(() => _refreshNotificationsAsync(), delay);
});
}
export async function downloadWorkOrderPdf(workOrderId) { export async function downloadWorkOrderPdf(workOrderId) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');
@@ -1022,28 +1029,34 @@ export async function downloadMonthlyCostsReport(year, month) {
export async function downloadMonthlyServiceTasksArchive(year, month) { export async function downloadMonthlyServiceTasksArchive(year, month) {
try { try {
const blob = await api.get( const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
`fleet/reports/monthly-service-tasks-archive/?year=${year}&month=${month}`, if (payload?.status === 'ready' && payload?.download_url) {
{ responseType: 'blob' }, showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success');
); _refreshNotificationsAsync();
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-SN.zip`); return payload;
showToast('ZIP pojedinačnih servisnih taskova je preuzet.', 'success'); }
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling();
return payload;
} catch (err) { } catch (err) {
showToast(err?.message || 'Preuzimanje ZIP-a servisnih taskova nije uspjelo.', 'error'); showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
throw err; throw err;
} }
} }
export async function downloadMonthlyWorkOrdersArchive(year, month) { export async function downloadMonthlyWorkOrdersArchive(year, month) {
try { try {
const blob = await api.get( const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
`fleet/reports/monthly-work-orders-archive/?year=${year}&month=${month}`, if (payload?.status === 'ready' && payload?.download_url) {
{ responseType: 'blob' }, showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success');
); _refreshNotificationsAsync();
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-PN.zip`); return payload;
showToast('ZIP putnih naloga i računa je preuzet.', 'success'); }
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling();
return payload;
} catch (err) { } catch (err) {
showToast(err?.message || 'Preuzimanje ZIP-a putnih naloga nije uspjelo.', 'error'); showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');
throw err; throw err;
} }
} }
@@ -1064,6 +1077,22 @@ export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'documen
} }
} }
export async function downloadGeneratedArchiveByUrl(downloadUrl, filename = 'archive.zip') {
if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje ZIP arhive.', 'error');
throw new Error('Nedostaje URL za preuzimanje ZIP arhive.');
}
try {
const blob = await api.get(downloadUrl, { responseType: 'blob' });
saveBlobToFile(blob, filename);
return true;
} catch (err) {
const msg = err?.message || 'Preuzimanje ZIP arhive nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export function openWorkOrderInvoicesPdfPage(workOrderId) { export function openWorkOrderInvoicesPdfPage(workOrderId) {
if (!isBrowser() || !workOrderId) { if (!isBrowser() || !workOrderId) {
return; return;