feat: add async monthly ZIP generation with notifications
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:
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user