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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user