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

@@ -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):