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>
4090 lines
161 KiB
Python
4090 lines
161 KiB
Python
# /backend/modules/fleet/views.py
|
|
|
|
from io import BytesIO
|
|
from io import StringIO
|
|
import base64
|
|
import csv
|
|
import mimetypes
|
|
import threading
|
|
import zipfile
|
|
import re
|
|
from collections import OrderedDict
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from decimal import Decimal, InvalidOperation
|
|
from django.contrib.auth import get_user_model
|
|
|
|
from PIL import Image, UnidentifiedImageError
|
|
from django.conf import settings
|
|
from django.core.mail import EmailMessage
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from django.core.validators import validate_email
|
|
from django.http import HttpResponse, FileResponse
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.html import escape
|
|
from django.db.models import Q
|
|
from django.db import transaction
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.lib.styles import getSampleStyleSheet
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.platypus import Table, TableStyle, Paragraph
|
|
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
|
from .email_utils import append_user_signature
|
|
from rest_framework import viewsets, permissions, status, mixins
|
|
from rest_framework.decorators import action, api_view, permission_classes
|
|
from rest_framework.response import Response
|
|
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
|
|
from rest_framework.parsers import MultiPartParser, FormParser
|
|
from celery.exceptions import TimeoutError as CeleryTimeoutError
|
|
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
from .models import (
|
|
Crane,
|
|
Vehicle,
|
|
VehicleNotification,
|
|
EmailDispatchLog,
|
|
ServiceContextNote,
|
|
MonthlyServicerDayEntry,
|
|
WorkOrder,
|
|
WorkOrderPhoto,
|
|
GeneratedWorkOrderPdf,
|
|
GeneratedFleetArchive,
|
|
WorkOrderInvoice,
|
|
WorkOrderAdditionalCostsTable,
|
|
VehicleServiceRecord,
|
|
VehicleServicePhoto,
|
|
VehicleServiceAttachment,
|
|
)
|
|
from .serializers import (
|
|
CraneSerializer,
|
|
VehicleSerializer,
|
|
WorkOrderSerializer,
|
|
WorkOrderInvoiceSerializer,
|
|
WorkOrderAdditionalCostsTableSerializer,
|
|
WorkOrderPhotoSerializer,
|
|
VehicleServiceRecordSerializer,
|
|
VehicleNotificationSerializer,
|
|
ServiceContextNoteSerializer,
|
|
ServiceContextNoteCreateSerializer,
|
|
MonthlyServicerDayEntrySerializer,
|
|
VehicleServicePhotoSerializer,
|
|
VehicleServiceAttachmentSerializer,
|
|
)
|
|
from infrastructure.pusher_service import PusherService
|
|
from .services import (
|
|
VehicleService,
|
|
VehicleServicePhotoService,
|
|
NotificationService,
|
|
)
|
|
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,
|
|
)
|
|
|
|
register_unicode_fonts()
|
|
User = get_user_model()
|
|
DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates'
|
|
SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx'
|
|
|
|
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
|
|
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
|
|
if asset_type:
|
|
queryset = queryset.filter(asset_type=asset_type)
|
|
if user.is_staff or getattr(user, 'is_serviser', False):
|
|
return queryset
|
|
return queryset
|
|
|
|
def _service_records_queryset_for_user(user):
|
|
queryset = (
|
|
VehicleServiceRecord.objects
|
|
.select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle')
|
|
.prefetch_related('photos', 'attachments')
|
|
.all()
|
|
)
|
|
if user.is_staff:
|
|
return queryset
|
|
return queryset.filter(performed_by=user)
|
|
|
|
|
|
def _work_orders_queryset_for_user(user):
|
|
queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all()
|
|
if user.is_staff:
|
|
return queryset
|
|
if getattr(user, 'is_serviser', False):
|
|
return queryset.filter(
|
|
Q(creator=user) | Q(vehicle__assigned_servicer=user)
|
|
).distinct()
|
|
return queryset.filter(creator=user)
|
|
|
|
|
|
def _is_supervisor_user(user):
|
|
return bool(getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False)))
|
|
|
|
|
|
def _active_team_members_queryset():
|
|
return User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
|
|
|
|
|
|
def _service_context_notes_queryset_for_user(user):
|
|
return (
|
|
ServiceContextNote.objects
|
|
.select_related('created_by', 'recipient', 'work_order', 'task')
|
|
.filter(is_active=True, recipient=user, is_closed=False)
|
|
.order_by('note_date', '-created_at')
|
|
)
|
|
|
|
|
|
def _work_order_related_tasks_queryset(work_order):
|
|
from modules.task_management.models import Task
|
|
|
|
direct_task_ids = list(
|
|
work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
|
|
)
|
|
inferred_task_ids = list(
|
|
VehicleServiceRecord.objects.filter(
|
|
is_active=True,
|
|
vehicle_id=work_order.vehicle_id,
|
|
task_id__isnull=False,
|
|
task__is_active=True,
|
|
).values_list('task_id', flat=True)
|
|
)
|
|
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
|
if not task_ids:
|
|
return Task.objects.none()
|
|
return Task.objects.filter(id__in=task_ids, is_active=True).select_related(
|
|
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
|
|
).order_by('-created_at')
|
|
|
|
def _can_access_service_record(user, service_record):
|
|
service_record_id = getattr(service_record, 'pk', service_record)
|
|
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
|
|
|
|
def _parse_positive_int(value, *, field_name, default, min_value, max_value):
|
|
if value in (None, ''):
|
|
return default
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."})
|
|
if parsed < min_value or parsed > max_value:
|
|
raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."})
|
|
return parsed
|
|
|
|
def _parse_format(value):
|
|
selected = (value or 'webp').lower()
|
|
supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'}
|
|
if selected not in supported:
|
|
raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."})
|
|
extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
|
|
return supported[selected], extension
|
|
|
|
|
|
GENERATED_PDF_TTL_HOURS = 24
|
|
GENERATED_ARCHIVE_TTL_DAYS = 7
|
|
|
|
|
|
def _work_order_display_code(work_order):
|
|
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
|
|
if normalized:
|
|
return normalized
|
|
return 'NALOG'
|
|
|
|
|
|
def _sanitize_task_title_for_filename(value):
|
|
normalized = re.sub(r'\s+', '_', str(value or '').strip())
|
|
normalized = re.sub(r'[^A-Za-z0-9_-]+', '', normalized)
|
|
normalized = normalized.strip('._-')
|
|
return normalized or 'servisni_zapis'
|
|
|
|
|
|
def _service_task_filename_label(task):
|
|
return f"SN-{_sanitize_task_title_for_filename(getattr(task, 'title', ''))}"
|
|
|
|
|
|
def _pdf_filename(work_order, pdf_type):
|
|
display_code = _work_order_display_code(work_order)
|
|
if pdf_type == 'invoices':
|
|
return f"{display_code}.work-order-invoices.pdf"
|
|
if pdf_type == 'service_records':
|
|
return f"{display_code}.work-order-service-records.pdf"
|
|
return f"{display_code}.work-order.pdf"
|
|
|
|
|
|
def _docx_filename(work_order, doc_type):
|
|
display_code = _work_order_display_code(work_order)
|
|
if doc_type == 'invoices':
|
|
return f"{display_code}.work-order-invoices.docx"
|
|
if doc_type == 'service_records':
|
|
return f"{display_code}.work-order-service-records.docx"
|
|
return f"{display_code}.work-order.docx"
|
|
|
|
|
|
def _service_records_pdf_filename(work_order, task=None):
|
|
if not task:
|
|
return _pdf_filename(work_order, 'service_records')
|
|
display_code = _work_order_display_code(work_order)
|
|
return f"{display_code}.{_service_task_filename_label(task)}.pdf"
|
|
|
|
|
|
def _service_records_docx_filename(work_order, task=None):
|
|
if not task:
|
|
return _docx_filename(work_order, 'service_records')
|
|
display_code = _work_order_display_code(work_order)
|
|
return f"{display_code}.{_service_task_filename_label(task)}.docx"
|
|
|
|
|
|
def _resolve_service_report_tasks(work_order, task_id):
|
|
queryset = _work_order_related_tasks_queryset(work_order)
|
|
if not task_id:
|
|
return list(queryset), None
|
|
selected_task = queryset.filter(pk=task_id).first()
|
|
if not selected_task:
|
|
raise DRFValidationError({
|
|
'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.'
|
|
})
|
|
return [selected_task], selected_task
|
|
|
|
|
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
|
"""
|
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
|
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
|
"""
|
|
try:
|
|
image_field.open('rb')
|
|
with Image.open(image_field) as src:
|
|
img = src.convert('RGB')
|
|
if img.width > max_width:
|
|
ratio = max_width / float(img.width)
|
|
new_h = max(1, int(img.height * ratio))
|
|
img = img.resize((max_width, new_h), Image.LANCZOS)
|
|
buf = BytesIO()
|
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
|
buf.seek(0)
|
|
return ImageReader(buf)
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
try:
|
|
image_field.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
|
"""
|
|
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
|
"""
|
|
try:
|
|
image_field.open('rb')
|
|
with Image.open(image_field) as src:
|
|
img = src.convert('RGB')
|
|
if img.width > max_width:
|
|
ratio = max_width / float(img.width)
|
|
new_h = max(1, int(img.height * ratio))
|
|
img = img.resize((max_width, new_h), Image.LANCZOS)
|
|
buf = BytesIO()
|
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
|
buf.seek(0)
|
|
return buf
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
try:
|
|
image_field.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _get_cached_pdf(work_order, pdf_type):
|
|
now = timezone.now()
|
|
expected_filename = _pdf_filename(work_order, pdf_type)
|
|
candidates = list(
|
|
GeneratedWorkOrderPdf.objects
|
|
.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
pdf_type=pdf_type,
|
|
status='ready',
|
|
expires_at__gt=now,
|
|
)
|
|
.exclude(file='')
|
|
.exclude(file__isnull=True)
|
|
.order_by('-created_at')
|
|
)
|
|
for candidate in candidates:
|
|
if str(candidate.filename or '').strip() == expected_filename:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _cleanup_expired_generated_pdfs():
|
|
now = timezone.now()
|
|
expired = GeneratedWorkOrderPdf.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 = 'PDF cache istekao.'
|
|
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _parse_amount_decimal(value):
|
|
if value in (None, ''):
|
|
return Decimal('0.00')
|
|
try:
|
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
|
return Decimal(normalized)
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return Decimal('0.00')
|
|
|
|
|
|
def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
|
cache_qs = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
)
|
|
if pdf_types:
|
|
cache_qs = cache_qs.filter(pdf_type__in=pdf_types)
|
|
for cached in cache_qs:
|
|
if cached.file:
|
|
cached.file.delete(save=False)
|
|
cached.is_active = False
|
|
cached.status = 'failed'
|
|
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
|
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _upsert_additional_cost_row_from_invoice(invoice):
|
|
if invoice is None or invoice.work_order_id is None:
|
|
return
|
|
table, _ = WorkOrderAdditionalCostsTable.objects.get_or_create(
|
|
work_order=invoice.work_order,
|
|
defaults={'data': {'rows': []}, 'total_for_payout': Decimal('0.00')},
|
|
)
|
|
rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
|
normalized_rows = [
|
|
row for row in rows
|
|
if isinstance(row, dict) and str(row.get('source_invoice_id', '')) != str(invoice.pk)
|
|
]
|
|
normalized_rows.append({
|
|
'naziv': str(invoice.naziv_racuna or '').strip() or f'Račun {invoice.pk}',
|
|
'broj_racuna': str(invoice.pk),
|
|
'ukupan_iznos': '0.00',
|
|
'prilog': Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '',
|
|
'source_invoice_id': str(invoice.pk),
|
|
})
|
|
total = sum(
|
|
(_parse_amount_decimal(row.get('ukupan_iznos')) for row in normalized_rows if isinstance(row, dict)),
|
|
Decimal('0.00'),
|
|
).quantize(Decimal('0.01'))
|
|
table.data = {'rows': normalized_rows}
|
|
table.total_for_payout = total
|
|
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
|
|
|
|
|
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
|
generated_pdf.file.open('rb')
|
|
filename = generated_pdf.filename or default_filename
|
|
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
response['Cache-Control'] = 'private, max-age=3600'
|
|
return response
|
|
|
|
|
|
def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None):
|
|
if user is None:
|
|
return
|
|
if doc_type == 'work_order':
|
|
doc_label = "putnog naloga"
|
|
elif doc_type == 'service_records':
|
|
doc_label = "servisnih zapisa putnog naloga"
|
|
else:
|
|
doc_label = "računa putnog naloga"
|
|
|
|
display_code = _work_order_display_code(work_order)
|
|
if stage == 'requested':
|
|
title = f"Zahtjev za PDF {doc_label}"
|
|
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za putni nalog {display_code}."
|
|
level = "info"
|
|
elif stage == 'failed':
|
|
title = f"Greška kod PDF-a ({doc_label})"
|
|
message = f"Generiranje PDF dokumenta nije uspjelo za putni nalog {display_code}."
|
|
level = "warning"
|
|
else:
|
|
title = f"PDF spreman ({doc_label})"
|
|
message = f"PDF dokument je uspješno generiran za putni nalog {display_code}."
|
|
level = "success"
|
|
|
|
metadata = {
|
|
"entity_type": "work_order_pdf",
|
|
"work_order_id": str(work_order.pk),
|
|
"vehicle_id": str(work_order.vehicle_id),
|
|
"section": "work-orders",
|
|
"pdf_type": doc_type,
|
|
"stage": stage,
|
|
}
|
|
if generated_pdf and generated_pdf.pk:
|
|
metadata["generated_pdf_id"] = str(generated_pdf.pk)
|
|
metadata["download_url"] = (
|
|
f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/"
|
|
)
|
|
metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type)
|
|
if generated_pdf.expires_at:
|
|
metadata["expires_at"] = generated_pdf.expires_at.isoformat()
|
|
|
|
NotificationService.create_notification(
|
|
recipient=user,
|
|
title=title,
|
|
message=message,
|
|
level=level,
|
|
send_email=False,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _request_cached_pdf_generation(*, request, work_order, pdf_type):
|
|
_cleanup_expired_generated_pdfs()
|
|
cached = _get_cached_pdf(work_order, pdf_type)
|
|
if cached:
|
|
_notify_pdf_request(
|
|
user=request.user,
|
|
work_order=work_order,
|
|
doc_type=pdf_type,
|
|
stage='completed',
|
|
generated_pdf=cached,
|
|
)
|
|
return {
|
|
"status": "ready",
|
|
"generated_pdf_id": str(cached.pk),
|
|
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/",
|
|
"filename": cached.filename or _pdf_filename(work_order, pdf_type),
|
|
"expires_at": cached.expires_at.isoformat() if cached.expires_at else None,
|
|
}
|
|
|
|
existing_pending = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
pdf_type=pdf_type,
|
|
status='pending',
|
|
).order_by('-created_at').first()
|
|
|
|
if existing_pending:
|
|
return {
|
|
"status": "processing",
|
|
"generated_pdf_id": str(existing_pending.pk),
|
|
}
|
|
|
|
generated_pdf = GeneratedWorkOrderPdf.objects.create(
|
|
work_order=work_order,
|
|
requested_by=request.user,
|
|
pdf_type=pdf_type,
|
|
status='pending',
|
|
filename=_pdf_filename(work_order, pdf_type),
|
|
expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS),
|
|
)
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested')
|
|
|
|
try:
|
|
build_work_order_pdf_cached_task.delay(str(generated_pdf.pk))
|
|
cleanup_expired_generated_pdfs_task.delay()
|
|
except KombuOperationalError:
|
|
build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get()
|
|
cleanup_expired_generated_pdfs_task.apply().get()
|
|
|
|
return {
|
|
"status": "processing",
|
|
"generated_pdf_id": str(generated_pdf.pk),
|
|
}
|
|
|
|
def _build_simple_pdf(title, rows):
|
|
buffer = BytesIO()
|
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
|
width, height = A4
|
|
y = height - 50
|
|
|
|
pdf.setFont("Vera-Bold", 14)
|
|
pdf.drawString(40, y, title)
|
|
y -= 24
|
|
|
|
pdf.setFont("Vera", 10)
|
|
for row in rows:
|
|
text = f"{row[0]}: {row[1]}"
|
|
if y < 60:
|
|
pdf.showPage()
|
|
pdf.setFont("Vera", 10)
|
|
y = height - 50
|
|
pdf.drawString(40, y, text[:140])
|
|
y -= 16
|
|
|
|
pdf.save()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _draw_pdf_row(pdf, *, label, value, y, width, height):
|
|
if y < 60:
|
|
pdf.showPage()
|
|
pdf.setFont("Vera", 10)
|
|
y = height - 50
|
|
text = f"{label}: {value}"
|
|
pdf.drawString(40, y, text[:180])
|
|
return y - 16
|
|
|
|
|
|
def _build_work_order_pdf(work_order):
|
|
vehicle = work_order.vehicle
|
|
creator = work_order.creator
|
|
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
|
|
now_local = timezone.localtime(timezone.now())
|
|
tz = timezone.get_current_timezone()
|
|
display_code = _work_order_display_code(work_order)
|
|
|
|
def _fmt_date(value):
|
|
if not value:
|
|
return '-'
|
|
if hasattr(value, 'strftime'):
|
|
return value.strftime('%d.%m.%Y.')
|
|
return str(value)
|
|
|
|
def _fmt_time(value):
|
|
if not value:
|
|
return '-'
|
|
return value.astimezone(tz).strftime('%H:%M')
|
|
|
|
def _fmt_eur(value):
|
|
try:
|
|
return f"{float(value):.2f} €"
|
|
except (TypeError, ValueError):
|
|
return "0,00 €"
|
|
|
|
def _parse_decimal(value):
|
|
if value in (None, ''):
|
|
return Decimal('0.00')
|
|
try:
|
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
|
return Decimal(normalized)
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return Decimal('0.00')
|
|
|
|
def _paragraph(text, *, bold=False, size=9, align=0):
|
|
styles = getSampleStyleSheet()
|
|
base = styles['BodyText'].clone('wo-p')
|
|
base.fontName = 'Vera-Bold' if bold else 'Vera'
|
|
base.fontSize = size
|
|
base.leading = size + 1.5
|
|
base.alignment = align
|
|
return Paragraph(text, base)
|
|
|
|
def _hours_between(start_at, end_at):
|
|
if not start_at or not end_at:
|
|
return 0.0
|
|
delta = end_at - start_at
|
|
return max(0.0, round(delta.total_seconds() / 3600.0, 2))
|
|
|
|
company_name = getattr(getattr(vehicle, 'client', None), 'name', None) or "KNEZ LJUBO d.o.o."
|
|
creator_name = _user_display_name(creator) or creator.email
|
|
creator_occupation = (getattr(creator, 'occupation', None) or '').strip() or (
|
|
"Servisni inženjer" if getattr(creator, 'is_serviser', False) else "Djelatnik"
|
|
)
|
|
creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
|
|
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
|
|
travel_start = work_order.travel_start_at
|
|
travel_end = work_order.travel_end_at
|
|
travel_hours = _hours_between(travel_start, travel_end)
|
|
daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
|
|
daily_rate = 0.0
|
|
daily_total = daily_qty * daily_rate
|
|
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
|
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
|
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
|
additional_table = getattr(work_order, 'additional_costs_table', None)
|
|
additional_rows_data = []
|
|
additional_total_decimal = Decimal('0.00')
|
|
if additional_table and isinstance(additional_table.data, dict):
|
|
additional_rows_data = additional_table.data.get('rows', []) if isinstance(additional_table.data.get('rows', []), list) else []
|
|
additional_total_decimal = _parse_decimal(additional_table.total_for_payout)
|
|
grand_total = daily_total + transport_total + float(additional_total_decimal)
|
|
|
|
attachment_names = [
|
|
str(row.get('prilog', '')).strip()
|
|
for row in additional_rows_data
|
|
if isinstance(row, dict) and str(row.get('prilog', '')).strip()
|
|
]
|
|
if not attachment_names:
|
|
attachment_names = [Path(inv.image.name).name for inv in invoices if inv.image and getattr(inv.image, 'name', '')]
|
|
attachments_text = ', '.join(attachment_names[:8]) if attachment_names else '-'
|
|
assigned_servicer_vehicle = (
|
|
creator.assigned_vehicles
|
|
.filter(asset_type='vehicle', is_active=True)
|
|
.order_by('registration_number')
|
|
.first()
|
|
)
|
|
assigned_servicer_vehicle_label = "-"
|
|
assigned_servicer_vehicle_registration = "-"
|
|
if assigned_servicer_vehicle:
|
|
assigned_servicer_vehicle_label = " ".join(
|
|
part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part
|
|
).strip() or assigned_servicer_vehicle.registration_number
|
|
assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-"
|
|
|
|
servicer_vehicle_label = (
|
|
assigned_servicer_vehicle_label
|
|
if assigned_servicer_vehicle
|
|
else (work_order.servicer_vehicle_make_model or '-')
|
|
)
|
|
servicer_vehicle_registration = (
|
|
assigned_servicer_vehicle_registration
|
|
if assigned_servicer_vehicle
|
|
else (work_order.servicer_vehicle_registration or '-')
|
|
)
|
|
|
|
buffer = BytesIO()
|
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
|
page_w, page_h = A4
|
|
margin = 16
|
|
content_w = page_w - (2 * margin)
|
|
y = page_h - 18
|
|
|
|
def draw_table(y_pos, data, widths, styles, row_heights=None):
|
|
table = Table(data, colWidths=widths, rowHeights=row_heights)
|
|
table.setStyle(TableStyle(styles))
|
|
_, table_h = table.wrap(content_w, page_h)
|
|
if y_pos - table_h < 28:
|
|
pdf.showPage()
|
|
y_pos = page_h - 18
|
|
table.drawOn(pdf, margin, y_pos - table_h)
|
|
return y_pos - table_h - 4
|
|
|
|
# 1) Zaglavlje + određujem da
|
|
section_1_top = [[
|
|
_paragraph(f"Trgovačko društvo<br/><b>{company_name}</b>", size=8.8),
|
|
_paragraph("N A L O G<br/>ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
|
|
_paragraph(
|
|
f"Mjesto i datum:<br/><b>{place_label} {_fmt_date(now_local.date())}</b><br/>"
|
|
f"Broj naloga:<br/><b>{display_code}</b>",
|
|
size=8.8,
|
|
),
|
|
]]
|
|
y = draw_table(
|
|
y,
|
|
section_1_top,
|
|
[content_w * 0.305, content_w * 0.35, content_w * 0.345],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 3),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
|
|
],
|
|
row_heights=[44],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[[_paragraph("Određujem da:", size=11)]],
|
|
[content_w],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[14],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[[
|
|
_paragraph(f"Ime i prezime:<br/><b>{creator_name}</b><br/><br/>Prebivalište:<br/><b>{creator_residence}</b>", size=8.7),
|
|
_paragraph(f"Zanimanje:<br/><b>{creator_occupation}</b><br/><br/>Na radnom mjestu:<br/><b>{creator_work_position}</b>", size=8.7),
|
|
]],
|
|
[content_w * 0.66, content_w * 0.34],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[58],
|
|
)
|
|
|
|
# 2) Putuje dana / mjesto / zadatak
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["Službeno otputuje dana:", "U mjesto:", "Sa zadaćom:"],
|
|
[
|
|
"1 dana" if travel_start else "-",
|
|
work_order.location or '-',
|
|
work_order.purpose or '-',
|
|
],
|
|
],
|
|
[content_w / 3, content_w / 3, content_w / 3],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.6),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
],
|
|
row_heights=[14, 19],
|
|
)
|
|
|
|
# 3) Trajanje + kilometraže
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["Putovanje može trajati:", "Datum polaska:", "Odobravamo upotrebu vozila:"],
|
|
[
|
|
"1 dana" if travel_start else "-",
|
|
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
|
f"{servicer_vehicle_label}, {servicer_vehicle_registration}",
|
|
],
|
|
["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
|
|
[
|
|
company_name,
|
|
"-",
|
|
str(work_order.servicer_vehicle_start_mileage or work_order.start_mileage or '-'),
|
|
],
|
|
["", "", "Završno stanje kilometara:"],
|
|
["", "", str(work_order.servicer_vehicle_end_mileage or work_order.end_mileage or '-')],
|
|
],
|
|
[content_w / 3, content_w / 3, content_w / 3],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTNAME', (0, 3), (2, 3), 'Vera-Bold'),
|
|
('FONTNAME', (2, 5), (2, 5), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
],
|
|
row_heights=[14, 18, 14, 18, 12, 14],
|
|
)
|
|
|
|
# 4) Obračun putnih troškova
|
|
section4_top = [
|
|
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
|
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
|
|
[
|
|
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
|
_fmt_time(travel_start),
|
|
_fmt_date(travel_end.date() if travel_end else work_order.date),
|
|
_fmt_time(travel_end),
|
|
str(travel_hours).replace('.', ','),
|
|
str(daily_qty).replace('.', ','),
|
|
_fmt_eur(daily_rate),
|
|
_fmt_eur(daily_total),
|
|
],
|
|
]
|
|
for _ in range(3):
|
|
section4_top.append(["", "", "", "", "", "", "", ""])
|
|
y = draw_table(
|
|
y,
|
|
section4_top,
|
|
[content_w * 0.12, content_w * 0.09, content_w * 0.12, content_w * 0.09, content_w * 0.11, content_w * 0.13, content_w * 0.17, content_w * 0.17],
|
|
[
|
|
('SPAN', (0, 0), (7, 0)),
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (7, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (7, 1), 'Vera'),
|
|
('FONTNAME', (0, 2), (7, 2), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('ALIGN', (0, 0), (7, 0), 'CENTER'),
|
|
('ALIGN', (0, 1), (7, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 2),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[15, 14, 14, 12, 12, 12],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
|
|
[origin_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
|
["", "", "", "", "", ""],
|
|
["", "", "", "", "", ""],
|
|
],
|
|
[content_w * 0.18, content_w * 0.18, content_w * 0.20, content_w * 0.10, content_w * 0.17, content_w * 0.17],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 2),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[14, 14, 12, 12],
|
|
)
|
|
|
|
# 5) Dodatni troškovi
|
|
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
|
|
if additional_rows_data:
|
|
for row in additional_rows_data[:4]:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
additional_rows.append([
|
|
str(row.get('naziv', '') or '-'),
|
|
str(row.get('broj_racuna', '') or '-'),
|
|
_fmt_eur(_parse_decimal(row.get('ukupan_iznos'))),
|
|
])
|
|
else:
|
|
for inv in invoices[:4]:
|
|
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
|
|
additional_total_row_idx = len(additional_rows)
|
|
additional_rows.append(["UKUPNO", "", _fmt_eur(additional_total_decimal)])
|
|
while len(additional_rows) < 7:
|
|
additional_rows.append(["", "", ""])
|
|
y = draw_table(
|
|
y,
|
|
additional_rows,
|
|
[content_w * 0.53, content_w * 0.30, content_w * 0.17],
|
|
[
|
|
('SPAN', (0, 0), (2, 0)),
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
|
|
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, additional_total_row_idx), (2, additional_total_row_idx), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.4),
|
|
('ALIGN', (0, 0), (2, 0), 'CENTER'),
|
|
('ALIGN', (2, 1), (2, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
],
|
|
row_heights=[14, 14, 12, 12, 12, 12, 12],
|
|
)
|
|
|
|
# 6) Prilozi + ukupna isplata
|
|
y = draw_table(
|
|
y,
|
|
[[
|
|
_paragraph(f"Prilozi:<br/>{attachments_text}", size=8.5),
|
|
_paragraph(
|
|
f"Ukupno za isplatu: <b>{_fmt_eur(grand_total)}</b><br/>"
|
|
f"Isplaćeno: <b>{_fmt_eur(0)}</b><br/>"
|
|
f"Ostaje za isplatu: <b>{_fmt_eur(grand_total)}</b>",
|
|
size=9,
|
|
),
|
|
]],
|
|
[content_w * 0.53, content_w * 0.47],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('TOPPADDING', (0, 0), (-1, -1), 3),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
|
|
],
|
|
row_heights=[48],
|
|
)
|
|
|
|
y -= 16
|
|
|
|
# Potpisi
|
|
if y < 64:
|
|
pdf.showPage()
|
|
y = page_h - 64
|
|
pdf.setFont("Vera", 9)
|
|
pdf.drawString(margin + 48, y, f"U {place_label} dana {_fmt_date(now_local.date())}")
|
|
y -= 20
|
|
sig_w = (content_w - 30) / 3
|
|
sig_labels = ["(Likvidator)", "(Podnositelj računa)", "(Isplatio blagajnik)"]
|
|
for i, label in enumerate(sig_labels):
|
|
x = margin + 10 + (i * sig_w)
|
|
pdf.line(x, y, x + sig_w - 24, y)
|
|
pdf.drawCentredString(x + (sig_w - 24) / 2, y - 12, label)
|
|
y -= 26
|
|
pdf.line(margin + 10, y, margin + sig_w - 14, y)
|
|
pdf.drawCentredString(margin + (sig_w - 4) / 2, y - 12, "(Primalac)")
|
|
pdf.line(margin + sig_w + 10, y, margin + 2 * sig_w - 14, y)
|
|
pdf.drawCentredString(margin + sig_w + (sig_w - 4) / 2, y - 12, "(Potpis direktora)")
|
|
pdf.drawString(margin + (2 * sig_w) + 34, y + 4, "M.P.")
|
|
|
|
pdf.save()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
|
vehicle = work_order.vehicle
|
|
client_name = getattr(vehicle.client, 'name', None) or '-'
|
|
manufacturer_str = str(vehicle.make or '-')
|
|
model_str = str(vehicle.model or '-')
|
|
serial_str = str(vehicle.crane_serial_number or '-')
|
|
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
|
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
|
mileage_str = str(vehicle.current_mileage or '-')
|
|
nalog_str = _work_order_display_code(work_order)
|
|
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
|
|
|
|
HEADER_H = 86
|
|
FOOTER_H = 72
|
|
MARGIN = 28
|
|
|
|
if related_tasks is None:
|
|
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
|
else:
|
|
related_tasks = list(related_tasks)
|
|
# Collect service records for all task vehicles (supports cross-crane work orders)
|
|
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
|
|
service_rows = list(
|
|
VehicleServiceRecord.objects.filter(
|
|
is_active=True,
|
|
vehicle_id__in=task_vehicle_ids if task_vehicle_ids else [work_order.vehicle_id],
|
|
)
|
|
.select_related('performed_by', 'task')
|
|
.prefetch_related('photos')
|
|
.order_by('service_date', 'created_at')
|
|
)
|
|
# Per-task notes from Task.service_report_note (edited via "Uredi tekst napomene")
|
|
task_note_parts = [
|
|
str(task.service_report_note or '').strip()
|
|
for task in related_tasks
|
|
if str(task.service_report_note or '').strip()
|
|
]
|
|
notes_text = "\n".join(task_note_parts) if task_note_parts else '-'
|
|
|
|
buffer = BytesIO()
|
|
|
|
class PageCanvas(canvas.Canvas):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._current_page = 0
|
|
|
|
def showPage(self):
|
|
self._current_page += 1
|
|
self._draw_header_footer()
|
|
super().showPage()
|
|
|
|
def save(self):
|
|
self._current_page += 1
|
|
self._draw_header_footer()
|
|
super().save()
|
|
|
|
def _draw_header_footer(self):
|
|
page_num = self._current_page
|
|
draw_standard_header_footer(
|
|
self,
|
|
page_num=page_num,
|
|
client_name=client_name,
|
|
manufacturer=manufacturer_str,
|
|
model=model_str,
|
|
serial=serial_str,
|
|
upgrade_hours=upgrade_hours_str,
|
|
chassis_hours=chassis_hours_str,
|
|
mileage=mileage_str,
|
|
work_order_number=nalog_str,
|
|
generated_date=generated_date_str,
|
|
report_title="Izvještaj servisera",
|
|
page_size=A4,
|
|
margin=MARGIN,
|
|
header_h=HEADER_H,
|
|
footer_h=FOOTER_H,
|
|
)
|
|
|
|
pdf = PageCanvas(buffer, pagesize=A4)
|
|
page_w, page_h = A4
|
|
content_top = page_h - HEADER_H - 10
|
|
content_bottom = FOOTER_H + 52
|
|
content_width = page_w - 2 * MARGIN
|
|
|
|
def new_page():
|
|
pdf.showPage()
|
|
return content_top
|
|
|
|
def draw_table(y, data, col_widths, style, row_heights=None):
|
|
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
|
|
table.setStyle(style)
|
|
_, h = table.wrap(content_width, page_h)
|
|
if y - h < content_bottom:
|
|
y = new_page()
|
|
table.drawOn(pdf, MARGIN, y - h)
|
|
return y - h - 10
|
|
|
|
paragraph_styles = getSampleStyleSheet()
|
|
description_style = paragraph_styles['BodyText'].clone('service-description')
|
|
description_style.fontName = 'Vera'
|
|
description_style.fontSize = 8.2
|
|
description_style.leading = 10.4
|
|
|
|
def _normalize_hours_table_row(row):
|
|
if not isinstance(row, dict):
|
|
return None
|
|
return {
|
|
'day': str(row.get('day', '') or '').strip() or '-',
|
|
'date': str(row.get('date', '') or '').strip() or '-',
|
|
'work_time': " - ".join(
|
|
[
|
|
str(row.get('work_time_from', '') or '').strip() or '-',
|
|
str(row.get('work_time_to', '') or '').strip() or '-',
|
|
]
|
|
),
|
|
'travel_time': " - ".join(
|
|
[
|
|
str(row.get('travel_time_from', '') or '').strip() or '-',
|
|
str(row.get('travel_time_to', '') or '').strip() or '-',
|
|
]
|
|
),
|
|
'break_hours': str(row.get('break_hours', '') or '').strip() or '-',
|
|
'work_hours': str(row.get('work_hours', '') or '').strip() or '-',
|
|
'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-',
|
|
'places': "\n".join([
|
|
f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}",
|
|
f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}",
|
|
]),
|
|
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
|
|
}
|
|
|
|
def _parse_decimal(value):
|
|
if value in (None, ''):
|
|
return 0.0
|
|
try:
|
|
return float(str(value).replace(',', '.'))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
def _format_decimal(value):
|
|
return f"{float(value):.1f}".replace('.', ',')
|
|
|
|
normalized_hours_rows = []
|
|
for task in related_tasks:
|
|
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
|
if not isinstance(table_data, dict):
|
|
continue
|
|
rows = table_data.get('rows', [])
|
|
if not isinstance(rows, list):
|
|
continue
|
|
for raw_row in rows:
|
|
normalized = _normalize_hours_table_row(raw_row)
|
|
if normalized:
|
|
normalized_hours_rows.append(normalized)
|
|
|
|
y = content_top
|
|
|
|
table1 = [
|
|
["Naziv Tvrtke:", client_name, "Lokacija intervencije:", work_order.location or '-'],
|
|
["Serviser:", _user_display_name(work_order.creator) or '-', "Asistirao:", '-'],
|
|
]
|
|
y = draw_table(
|
|
y,
|
|
table1,
|
|
[95, 165, 110, content_width - 370],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
|
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
|
|
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
|
table2 = [
|
|
["Prijevozno sredstvo:", "Registracija:", "Broj narudžbe klijenta:", "Posao završen"],
|
|
[work_order.servicer_vehicle_make_model or '-', work_order.servicer_vehicle_registration or '-', "-", completion_label],
|
|
]
|
|
y = draw_table(
|
|
y,
|
|
table2,
|
|
[152, 100, 147, content_width - 399],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
]),
|
|
row_heights=[17, 17],
|
|
)
|
|
|
|
table3_headers = [
|
|
"Dan",
|
|
"Datum",
|
|
"Vrijeme rada\nod-do",
|
|
"Vrijeme putovanja\nod-do",
|
|
"Pauza\nh",
|
|
"Sati\nrada",
|
|
"Sati\nputa",
|
|
"Mjesto polaska / dolaska",
|
|
"Kilometri\nvozila",
|
|
]
|
|
table3_rows = [table3_headers]
|
|
total_hours_travel = 0.0
|
|
total_vehicle_km = 0.0
|
|
|
|
if normalized_hours_rows:
|
|
for row in normalized_hours_rows:
|
|
table3_rows.append([
|
|
row['day'],
|
|
row['date'],
|
|
row['work_time'],
|
|
row['travel_time'],
|
|
row['break_hours'],
|
|
row['work_hours'],
|
|
row['travel_hours'],
|
|
row['places'],
|
|
row['vehicle_km'],
|
|
])
|
|
total_hours_travel += _parse_decimal(row['travel_hours'])
|
|
total_vehicle_km += _parse_decimal(row['vehicle_km'])
|
|
else:
|
|
for _ in range(12):
|
|
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
|
|
|
|
table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')])
|
|
y = draw_table(
|
|
y,
|
|
table3_rows,
|
|
[28, 54, 72, 72, 36, 40, 40, 97, content_width - 439],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 7.8),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('ALIGN', (0, 0), (0, -1), 'CENTER'),
|
|
('ALIGN', (1, 0), (6, -1), 'CENTER'),
|
|
('SPAN', (0, -1), (5, -1)),
|
|
('ALIGN', (0, -1), (5, -1), 'LEFT'),
|
|
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
|
|
('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
|
|
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('LEFTPADDING', (0, -1), (5, -1), 12),
|
|
('VALIGN', (7, 1), (7, -2), 'TOP'),
|
|
('ALIGN', (7, 1), (7, -2), 'LEFT'),
|
|
]),
|
|
)
|
|
|
|
if y < content_bottom + 24:
|
|
y = new_page()
|
|
pdf.setFont("Vera", 9)
|
|
pdf.drawString(MARGIN, y, f"Napomene: {notes_text or '-'}")
|
|
|
|
# Sljedeća stranica: servisni zapisi.
|
|
y = new_page()
|
|
y -= 14
|
|
pdf.setFont("Vera-Bold", 12)
|
|
pdf.drawString(MARGIN, y, "Servisni zapisi")
|
|
y -= 18
|
|
|
|
if not related_tasks:
|
|
pdf.setFont("Vera", 9)
|
|
pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.")
|
|
else:
|
|
for task in related_tasks:
|
|
task_records = [
|
|
row for row in service_rows
|
|
if getattr(row, 'task_id', None) == task.id
|
|
]
|
|
if not task_records:
|
|
continue
|
|
|
|
if y < content_bottom + 60:
|
|
y = new_page()
|
|
pdf.setFont("Vera-Bold", 10)
|
|
pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95])
|
|
y -= 14
|
|
|
|
for record in task_records:
|
|
if y < content_bottom + 64:
|
|
y = new_page()
|
|
|
|
description_table = [[Paragraph(record.description or '-', description_style)]]
|
|
y = draw_table(
|
|
y,
|
|
[["Servisni opis"], *description_table],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
parts_table = [[Paragraph(record.parts or '-', description_style)]]
|
|
y = draw_table(
|
|
y,
|
|
[["Korišteni dijelovi"], *parts_table],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image]
|
|
if not photos:
|
|
continue
|
|
if y < content_bottom + 30:
|
|
y = new_page()
|
|
y -= 8
|
|
pdf.setFont("Vera", 8)
|
|
pdf.drawString(MARGIN, y, "Fotografije:")
|
|
y -= 10
|
|
|
|
image_gap = 10
|
|
max_image_height = 110
|
|
image_width = (content_width - image_gap) / 2
|
|
for index in range(0, len(photos), 2):
|
|
row_photos = photos[index:index + 2]
|
|
prepared = []
|
|
for photo in row_photos:
|
|
try:
|
|
image_reader = _compress_image_for_pdf(photo.image)
|
|
if image_reader is None:
|
|
continue
|
|
source_w, source_h = image_reader.getSize()
|
|
if not source_w or not source_h:
|
|
continue
|
|
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
|
|
prepared.append((photo, image_reader, scaled_h))
|
|
except Exception:
|
|
continue
|
|
if not prepared:
|
|
continue
|
|
|
|
caption_height = 12
|
|
row_height = max(item[2] for item in prepared) + caption_height
|
|
if y - row_height < content_bottom:
|
|
y = new_page()
|
|
|
|
base_y = y - caption_height
|
|
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
|
|
image_x = MARGIN + photo_index * (image_width + image_gap)
|
|
image_y = base_y - scaled_h
|
|
pdf.drawImage(
|
|
image_reader,
|
|
image_x,
|
|
image_y,
|
|
width=image_width,
|
|
height=scaled_h,
|
|
preserveAspectRatio=True,
|
|
anchor='c',
|
|
mask='auto',
|
|
)
|
|
pdf.setFont("Vera", 7)
|
|
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
|
|
pdf.drawString(image_x, image_y - 10, caption[:52])
|
|
|
|
y = base_y - max(item[2] for item in prepared) - 14
|
|
|
|
pdf.save()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _build_service_record_pdf(service_record):
|
|
vehicle = service_record.vehicle
|
|
task = getattr(service_record, 'task', None)
|
|
work_order = getattr(task, 'work_order', None) if task else None
|
|
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
|
manufacturer_str = str(vehicle.make or '-')
|
|
model_str = str(vehicle.model or '-')
|
|
serial_str = str(vehicle.crane_serial_number or '-')
|
|
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
|
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
|
mileage_str = str(vehicle.current_mileage or '-')
|
|
nalog_str = _work_order_display_code(work_order) if work_order else '-'
|
|
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
|
|
|
|
HEADER_H = 86
|
|
FOOTER_H = 72
|
|
MARGIN = 28
|
|
|
|
def _fmt_date(value):
|
|
if not value:
|
|
return '-'
|
|
return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value)
|
|
|
|
buffer = BytesIO()
|
|
|
|
class PageCanvas(canvas.Canvas):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._current_page = 0
|
|
|
|
def showPage(self):
|
|
self._current_page += 1
|
|
self._draw_header_footer()
|
|
super().showPage()
|
|
|
|
def save(self):
|
|
self._current_page += 1
|
|
self._draw_header_footer()
|
|
super().save()
|
|
|
|
def _draw_header_footer(self):
|
|
page_num = self._current_page
|
|
draw_standard_header_footer(
|
|
self,
|
|
page_num=page_num,
|
|
client_name=client_name,
|
|
manufacturer=manufacturer_str,
|
|
model=model_str,
|
|
serial=serial_str,
|
|
upgrade_hours=upgrade_hours_str,
|
|
chassis_hours=chassis_hours_str,
|
|
mileage=mileage_str,
|
|
work_order_number=nalog_str,
|
|
generated_date=generated_date_str,
|
|
report_title="Izvještaj servisera",
|
|
page_size=A4,
|
|
margin=MARGIN,
|
|
header_h=HEADER_H,
|
|
footer_h=FOOTER_H,
|
|
)
|
|
|
|
pdf = PageCanvas(buffer, pagesize=A4)
|
|
page_w, page_h = A4
|
|
content_top = page_h - HEADER_H - 10
|
|
content_bottom = FOOTER_H + 52
|
|
content_width = page_w - 2 * MARGIN
|
|
|
|
def new_page():
|
|
pdf.showPage()
|
|
return content_top
|
|
|
|
def draw_table(y, data, col_widths, style, row_heights=None):
|
|
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
|
|
table.setStyle(style)
|
|
_, h = table.wrap(content_width, page_h)
|
|
if y - h < content_bottom:
|
|
y = new_page()
|
|
table.drawOn(pdf, MARGIN, y - h)
|
|
return y - h - 10
|
|
|
|
paragraph_styles = getSampleStyleSheet()
|
|
description_style = paragraph_styles['BodyText'].clone('service-record-description')
|
|
description_style.fontName = 'Vera'
|
|
description_style.fontSize = 8.2
|
|
description_style.leading = 10.4
|
|
|
|
y = content_top - 6
|
|
y -= 6
|
|
|
|
task_scheduled_date = getattr(task, 'scheduled_date', None) if task else None
|
|
info_rows = [
|
|
["ID", str(service_record.pk), "Datum", _fmt_date(task_scheduled_date)],
|
|
["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'],
|
|
["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'],
|
|
["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')],
|
|
["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')],
|
|
]
|
|
y = draw_table(
|
|
y,
|
|
info_rows,
|
|
[78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
|
|
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
y = draw_table(
|
|
y,
|
|
[["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
y = draw_table(
|
|
y,
|
|
[["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
attachments = list(service_record.attachments.filter(is_active=True).all())
|
|
attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file]
|
|
y = draw_table(
|
|
y,
|
|
[["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image]
|
|
if photos:
|
|
if y < content_bottom + 30:
|
|
y = new_page()
|
|
y -= 8
|
|
pdf.setFont("Vera", 8)
|
|
pdf.drawString(MARGIN, y, "Fotografije:")
|
|
y -= 10
|
|
|
|
image_gap = 10
|
|
max_image_height = 110
|
|
image_width = (content_width - image_gap) / 2
|
|
for index in range(0, len(photos), 2):
|
|
row_photos = photos[index:index + 2]
|
|
prepared = []
|
|
for photo in row_photos:
|
|
try:
|
|
image_reader = _compress_image_for_pdf(photo.image)
|
|
if image_reader is None:
|
|
continue
|
|
source_w, source_h = image_reader.getSize()
|
|
if not source_w or not source_h:
|
|
continue
|
|
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
|
|
prepared.append((photo, image_reader, scaled_h))
|
|
except (OSError, ValueError, TypeError, UnidentifiedImageError):
|
|
continue
|
|
if not prepared:
|
|
continue
|
|
|
|
caption_height = 12
|
|
row_height = max(item[2] for item in prepared) + caption_height
|
|
if y - row_height < content_bottom:
|
|
y = new_page()
|
|
|
|
base_y = y - caption_height
|
|
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
|
|
image_x = MARGIN + photo_index * (image_width + image_gap)
|
|
image_y = base_y - scaled_h
|
|
pdf.drawImage(
|
|
image_reader,
|
|
image_x,
|
|
image_y,
|
|
width=image_width,
|
|
height=scaled_h,
|
|
preserveAspectRatio=True,
|
|
anchor='c',
|
|
mask='auto',
|
|
)
|
|
pdf.setFont("Vera", 7)
|
|
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
|
|
pdf.drawString(image_x, image_y - 10, caption[:52])
|
|
|
|
y = base_y - max(item[2] for item in prepared) - 14
|
|
else:
|
|
y = draw_table(
|
|
y,
|
|
[["Fotografije"], ["-"]],
|
|
[content_width],
|
|
TableStyle([
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
]),
|
|
)
|
|
|
|
pdf.save()
|
|
return buffer.getvalue()
|
|
|
|
def _first_non_empty(*values):
|
|
for value in values:
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
def _user_display_name(user):
|
|
if not user:
|
|
return None
|
|
full_name = user.get_full_name()
|
|
if full_name:
|
|
return full_name
|
|
if getattr(user, 'email', None):
|
|
return user.email
|
|
if getattr(user, 'username', None):
|
|
return user.username
|
|
return str(getattr(user, 'pk', ''))
|
|
|
|
def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
|
|
if not recipient:
|
|
raise DRFValidationError({"recipient": "Email primatelja je obavezan."})
|
|
message = EmailMessage(
|
|
subject=subject,
|
|
body=body,
|
|
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None),
|
|
to=[recipient],
|
|
)
|
|
message.attach(filename, pdf_bytes, 'application/pdf')
|
|
message.send(fail_silently=False)
|
|
|
|
|
|
def _parse_boolean_flag(value, *, field_name, default=False):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
normalized = str(value).strip().lower()
|
|
if normalized in {'1', 'true', 'yes', 'da', 'on'}:
|
|
return True
|
|
if normalized in {'0', 'false', 'no', 'ne', 'off'}:
|
|
return False
|
|
raise DRFValidationError({field_name: f"Neispravna boolean vrijednost za {field_name}."})
|
|
|
|
|
|
def _parse_recipients(payload, *, fallback_candidates):
|
|
raw_recipients = payload.get('recipients')
|
|
if raw_recipients is None:
|
|
single = payload.get('recipient')
|
|
raw_recipients = [single] if single is not None else []
|
|
elif isinstance(raw_recipients, str):
|
|
raw_recipients = [raw_recipients]
|
|
elif not isinstance(raw_recipients, list):
|
|
raise DRFValidationError({'recipients': 'Polje recipients mora biti lista email adresa.'})
|
|
|
|
prepared = []
|
|
for item in raw_recipients:
|
|
if not isinstance(item, str):
|
|
raise DRFValidationError({'recipients': 'Svaki primatelj mora biti email adresa.'})
|
|
chunks = [chunk.strip() for chunk in item.split(',')]
|
|
prepared.extend(chunk for chunk in chunks if chunk)
|
|
|
|
if not prepared:
|
|
prepared = [candidate for candidate in fallback_candidates if isinstance(candidate, str) and candidate.strip()]
|
|
|
|
unique = OrderedDict()
|
|
for email in prepared:
|
|
normalized = email.strip()
|
|
if not normalized:
|
|
continue
|
|
try:
|
|
validate_email(normalized)
|
|
except DjangoValidationError:
|
|
raise DRFValidationError({'recipients': f'Neispravna email adresa: {normalized}.'})
|
|
unique[normalized.lower()] = normalized
|
|
return list(unique.values())
|
|
|
|
|
|
def _build_work_order_invoices_pdf_bytes(work_order):
|
|
try:
|
|
task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
|
|
payload = task_result.get(timeout=45)
|
|
except KombuOperationalError:
|
|
payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get()
|
|
except CeleryTimeoutError:
|
|
raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."})
|
|
|
|
if not isinstance(payload, dict) or payload.get('error'):
|
|
raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."})
|
|
pdf_b64 = payload.get('pdf_base64')
|
|
if not pdf_b64:
|
|
raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."})
|
|
try:
|
|
return base64.b64decode(pdf_b64)
|
|
except (ValueError, TypeError):
|
|
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
|
|
|
|
|
|
def _create_docx_document(template_name=None):
|
|
try:
|
|
from docx import Document
|
|
except ModuleNotFoundError:
|
|
raise DRFValidationError({"detail": "DOCX generiranje nije dostupno: nedostaje python-docx paket."})
|
|
if not template_name:
|
|
document = Document()
|
|
_remove_docx_edit_restrictions(document)
|
|
return document
|
|
template_path = DOCX_TEMPLATE_DIR / template_name
|
|
if not template_path.exists():
|
|
raise DRFValidationError({"detail": f"DOCX template nije pronađen: {template_name}."})
|
|
document = Document(str(template_path))
|
|
_remove_docx_edit_restrictions(document)
|
|
return document
|
|
|
|
|
|
def _remove_docx_edit_restrictions(document):
|
|
try:
|
|
settings_element = document.settings.element
|
|
except AttributeError:
|
|
return document
|
|
|
|
protected_tags = (
|
|
'{http://schemas.openxmlformats.org/wordprocessingml/2006/main}documentProtection',
|
|
'{http://schemas.openxmlformats.org/wordprocessingml/2006/main}writeProtection',
|
|
'{http://schemas.openxmlformats.org/wordprocessingml/2006/main}readOnlyRecommended',
|
|
)
|
|
for tag_name in protected_tags:
|
|
for element in list(settings_element.findall(tag_name)):
|
|
settings_element.remove(element)
|
|
return document
|
|
|
|
|
|
def _set_docx_cell_text(table, row_index, col_index, value):
|
|
row_count = len(getattr(table, 'rows', []))
|
|
if row_count <= row_index:
|
|
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."})
|
|
col_count = len(getattr(table.rows[row_index], 'cells', []))
|
|
if col_count <= col_index:
|
|
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."})
|
|
table.rows[row_index].cells[col_index].text = str(value or '-')
|
|
|
|
|
|
def _docx_add_heading(document, text, level=1):
|
|
style_name = f'Heading {int(level)}'
|
|
try:
|
|
document.styles[style_name]
|
|
return document.add_paragraph(str(text or ''), style=style_name)
|
|
except KeyError:
|
|
paragraph = document.add_paragraph()
|
|
run = paragraph.add_run(str(text or ''))
|
|
run.bold = True
|
|
return paragraph
|
|
|
|
|
|
def _normalize_whitespace(value):
|
|
return " ".join(str(value or '').split()).strip()
|
|
|
|
|
|
def _docx_remove_rows_after(table, keep_rows=1):
|
|
rows = list(getattr(table, 'rows', []))
|
|
for row in rows[keep_rows:]:
|
|
table._tbl.remove(row._tr)
|
|
|
|
|
|
def _docx_remove_table(table):
|
|
parent = table._tbl.getparent()
|
|
if parent is not None:
|
|
parent.remove(table._tbl)
|
|
|
|
|
|
def _docx_move_table_after_paragraph_text(document, table, marker_text):
|
|
if not marker_text:
|
|
return False
|
|
marker_norm = _normalize_whitespace(marker_text).lower()
|
|
if not marker_norm:
|
|
return False
|
|
body = document._body._element
|
|
children = list(body)
|
|
table_element = table._tbl
|
|
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
|
|
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
|
|
for index, element in enumerate(children):
|
|
if element.tag != paragraph_namespace:
|
|
continue
|
|
paragraph_text = ''.join(node.text or '' for node in element.iter(text_namespace))
|
|
if marker_norm not in _normalize_whitespace(paragraph_text).lower():
|
|
continue
|
|
if table_element in children:
|
|
body.remove(table_element)
|
|
children = list(body)
|
|
index = children.index(element)
|
|
body.insert(index + 1, table_element)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _extract_unique_parts_entries(records):
|
|
unique = OrderedDict()
|
|
for record in records:
|
|
raw_parts = _normalize_whitespace(getattr(record, 'parts', None))
|
|
if not raw_parts or raw_parts == '-':
|
|
continue
|
|
key = raw_parts.lower()
|
|
if key in unique:
|
|
continue
|
|
serial = '-'
|
|
description = raw_parts
|
|
match = re.match(r'^\[([^\]]+)\]\s*(.*)$', raw_parts)
|
|
if match:
|
|
serial = _normalize_whitespace(match.group(1)) or '-'
|
|
description = _normalize_whitespace(match.group(2)) or '-'
|
|
related_task = getattr(record, 'task', None)
|
|
changed_at_value = '-'
|
|
if related_task and getattr(related_task, 'scheduled_date', None):
|
|
changed_at_value = related_task.scheduled_date.strftime('%d.%m.%Y')
|
|
unique[key] = {
|
|
'serial': serial,
|
|
'description': description,
|
|
'note': '-',
|
|
'changed_at': changed_at_value,
|
|
}
|
|
return list(unique.values())
|
|
|
|
|
|
def _docx_cleanup_service_report_template(document):
|
|
body = document._body._element
|
|
children = list(body)
|
|
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
|
|
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
|
|
seen_hours_heading = False
|
|
seen_note = False
|
|
remove_tail = False
|
|
|
|
for element in children:
|
|
tag = element.tag
|
|
if tag == paragraph_namespace:
|
|
text = ''.join(node.text or '' for node in element.iter(text_namespace))
|
|
normalized = _normalize_whitespace(text)
|
|
normalized_lower = normalized.lower()
|
|
|
|
if 'servisni zapisi' in normalized_lower:
|
|
remove_tail = True
|
|
if remove_tail:
|
|
body.remove(element)
|
|
continue
|
|
|
|
if normalized_lower == 'tablica radnih sati':
|
|
if seen_hours_heading:
|
|
body.remove(element)
|
|
continue
|
|
seen_hours_heading = True
|
|
|
|
if normalized_lower.startswith('napomene:'):
|
|
if seen_note:
|
|
body.remove(element)
|
|
continue
|
|
seen_note = True
|
|
elif remove_tail and not tag.endswith('sectPr'):
|
|
body.remove(element)
|
|
|
|
|
|
def _docx_remove_empty_page_break_paragraphs(document):
|
|
body = document._body._element
|
|
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
|
|
break_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}br'
|
|
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
|
|
wordprocessing_namespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
|
|
for element in list(body):
|
|
if element.tag != paragraph_namespace:
|
|
continue
|
|
text = ''.join(node.text or '' for node in element.iter(text_namespace)).strip()
|
|
if text:
|
|
continue
|
|
has_page_break = any(
|
|
br.tag == break_namespace and br.get(f'{{{wordprocessing_namespace}}}type') == 'page'
|
|
for br in element.iter(break_namespace)
|
|
)
|
|
if has_page_break:
|
|
body.remove(element)
|
|
|
|
|
|
def _build_work_order_docx_bytes(work_order):
|
|
vehicle = work_order.vehicle
|
|
creator = work_order.creator
|
|
doc = _create_docx_document()
|
|
_docx_add_heading(doc, f'Putni nalog {_work_order_display_code(work_order)}', level=1)
|
|
|
|
rows = [
|
|
('Datum naloga', str(work_order.date or '-')),
|
|
('Serviser', _user_display_name(creator) or getattr(creator, 'email', '-') or '-'),
|
|
('Klijent', getattr(getattr(vehicle, 'client', None), 'name', None) or '-'),
|
|
('Dizalica', " ".join(part for part in [vehicle.registration_number, vehicle.make, vehicle.model] if part) or '-'),
|
|
('Lokacija', work_order.location or '-'),
|
|
('Ishodište', work_order.origin_location or '-'),
|
|
('Svrha', work_order.purpose or '-'),
|
|
('Status', work_order.status or '-'),
|
|
('Napomene', work_order.notes or '-'),
|
|
]
|
|
table = doc.add_table(rows=1, cols=2)
|
|
table.rows[0].cells[0].text = 'Polje'
|
|
table.rows[0].cells[1].text = 'Vrijednost'
|
|
for label, value in rows:
|
|
row_cells = table.add_row().cells
|
|
row_cells[0].text = str(label)
|
|
row_cells[1].text = str(value)
|
|
|
|
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
|
|
if additional_costs_table and isinstance(additional_costs_table.data, dict):
|
|
additional_rows = additional_costs_table.data.get('rows', [])
|
|
if isinstance(additional_rows, list) and additional_rows:
|
|
doc.add_paragraph()
|
|
_docx_add_heading(doc, 'Dodatni troškovi', level=2)
|
|
costs_table = doc.add_table(rows=1, cols=4)
|
|
costs_table.rows[0].cells[0].text = 'Naziv'
|
|
costs_table.rows[0].cells[1].text = 'Broj računa'
|
|
costs_table.rows[0].cells[2].text = 'Ukupan iznos'
|
|
costs_table.rows[0].cells[3].text = 'Prilog'
|
|
for entry in additional_rows:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
row_cells = costs_table.add_row().cells
|
|
row_cells[0].text = str(entry.get('naziv') or '-')
|
|
row_cells[1].text = str(entry.get('broj_racuna') or '-')
|
|
row_cells[2].text = str(entry.get('ukupan_iznos') or '-')
|
|
row_cells[3].text = str(entry.get('prilog') or '-')
|
|
|
|
buffer = BytesIO()
|
|
doc.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None):
|
|
from docx.shared import Cm
|
|
|
|
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
|
|
vehicle = work_order.vehicle
|
|
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
|
servicer_name = _user_display_name(work_order.creator) or '-'
|
|
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
|
|
|
if related_tasks is None:
|
|
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
|
|
else:
|
|
related_tasks = list(related_tasks)
|
|
normalized_rows = []
|
|
for task in related_tasks:
|
|
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
|
if not isinstance(table_data, dict):
|
|
continue
|
|
rows = table_data.get('rows', [])
|
|
if not isinstance(rows, list):
|
|
continue
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
normalized_rows.append({
|
|
'day': str(row.get('day', '') or '').strip() or '-',
|
|
'date': str(row.get('date', '') or '').strip() or '-',
|
|
'work_time': " - ".join([
|
|
str(row.get('work_time_from', '') or '').strip() or '-',
|
|
str(row.get('work_time_to', '') or '').strip() or '-',
|
|
]),
|
|
'travel_time': " - ".join([
|
|
str(row.get('travel_time_from', '') or '').strip() or '-',
|
|
str(row.get('travel_time_to', '') or '').strip() or '-',
|
|
]),
|
|
'break_hours': str(row.get('break_hours', '') or '').strip() or '-',
|
|
'work_hours': str(row.get('work_hours', '') or '').strip() or '-',
|
|
'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-',
|
|
'places': "\n".join([
|
|
f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}",
|
|
f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}",
|
|
]),
|
|
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
|
|
})
|
|
|
|
# Collect service records per task, filtering by each task's own vehicle
|
|
# (supports cross-crane work orders where each task may have a different crane).
|
|
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
|
|
service_rows = list(
|
|
VehicleServiceRecord.objects.filter(
|
|
is_active=True,
|
|
vehicle_id__in=task_vehicle_ids,
|
|
)
|
|
.select_related('performed_by', 'task')
|
|
.prefetch_related('photos')
|
|
.order_by('service_date', 'created_at')
|
|
)
|
|
records_by_task = {
|
|
task.id: [row for row in service_rows if getattr(row, 'task_id', None) == task.id]
|
|
for task in related_tasks
|
|
}
|
|
|
|
# Build notes only from per-task service_report_note (edited via "Uredi tekst napomene")
|
|
task_note_parts = [
|
|
str(task.service_report_note or '').strip()
|
|
for task in related_tasks
|
|
if str(task.service_report_note or '').strip()
|
|
]
|
|
notes_text = "\n".join(task_note_parts) if task_note_parts else '-'
|
|
|
|
if len(doc.tables) >= 3:
|
|
info_table = doc.tables[0]
|
|
_set_docx_cell_text(info_table, 1, 0, client_name)
|
|
_set_docx_cell_text(info_table, 1, 1, work_order.location or '-')
|
|
_set_docx_cell_text(info_table, 3, 0, servicer_name)
|
|
if len(info_table.rows[3].cells) > 1:
|
|
_set_docx_cell_text(info_table, 3, 1, '-')
|
|
|
|
transport_table = doc.tables[1]
|
|
_set_docx_cell_text(transport_table, 1, 0, work_order.servicer_vehicle_make_model or '-')
|
|
_set_docx_cell_text(transport_table, 1, 1, work_order.servicer_vehicle_registration or '-')
|
|
_set_docx_cell_text(transport_table, 1, 2, '-')
|
|
_set_docx_cell_text(transport_table, 1, 4, completion_label)
|
|
|
|
all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])]
|
|
summary_table = doc.tables[2]
|
|
_docx_remove_rows_after(summary_table, keep_rows=1)
|
|
unique_parts_entries = _extract_unique_parts_entries(all_task_records)
|
|
summary_rows = unique_parts_entries or [{
|
|
'serial': '-',
|
|
'description': '-',
|
|
'note': '-',
|
|
'changed_at': '-',
|
|
}]
|
|
for index, entry in enumerate(summary_rows):
|
|
target_row = summary_table.rows[1] if index == 0 and len(summary_table.rows) > 1 else summary_table.add_row()
|
|
target_row.cells[0].text = entry['serial']
|
|
target_row.cells[1].text = entry['description']
|
|
target_row.cells[2].text = entry['note']
|
|
target_row.cells[3].text = entry['changed_at']
|
|
if len(doc.tables) > 3:
|
|
_docx_remove_table(doc.tables[3])
|
|
else:
|
|
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
|
|
|
|
for paragraph in doc.paragraphs:
|
|
paragraph_text = str(getattr(paragraph, 'text', '') or '').strip()
|
|
if paragraph_text.lower().startswith('napomene:'):
|
|
paragraph.text = f"Napomene: {notes_text}"
|
|
break
|
|
|
|
hours_table = None
|
|
for table in doc.tables:
|
|
if not table.rows:
|
|
continue
|
|
headers = [_normalize_whitespace(cell.text).lower() for cell in table.rows[0].cells]
|
|
if len(headers) >= 9 and headers[0] == 'dan' and headers[1] == 'datum':
|
|
hours_table = table
|
|
break
|
|
if hours_table is None:
|
|
_docx_add_heading(doc, 'Tablica radnih sati', level=2)
|
|
hours_table = doc.add_table(rows=1, cols=9)
|
|
headers = ['Dan', 'Datum', 'Vrijeme rada', 'Vrijeme putovanja', 'Pauza h', 'Sati rada', 'Sati puta', 'Polazak / dolazak', 'Km vozila']
|
|
for index, header in enumerate(headers):
|
|
hours_table.rows[0].cells[index].text = header
|
|
_docx_remove_rows_after(hours_table, keep_rows=1)
|
|
if normalized_rows:
|
|
for row in normalized_rows:
|
|
cells = hours_table.add_row().cells
|
|
cells[0].text = row['day']
|
|
cells[1].text = row['date']
|
|
cells[2].text = row['work_time']
|
|
cells[3].text = row['travel_time']
|
|
cells[4].text = row['break_hours']
|
|
cells[5].text = row['work_hours']
|
|
cells[6].text = row['travel_hours']
|
|
cells[7].text = row['places']
|
|
cells[8].text = row['vehicle_km']
|
|
else:
|
|
cells = hours_table.add_row().cells
|
|
for index in range(9):
|
|
cells[index].text = '-'
|
|
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
|
|
_docx_cleanup_service_report_template(doc)
|
|
_docx_remove_empty_page_break_paragraphs(doc)
|
|
doc.add_page_break()
|
|
_docx_add_heading(doc, 'Servisni zapisi', level=2)
|
|
|
|
has_records = False
|
|
for task in related_tasks:
|
|
task_records = records_by_task.get(task.id, [])
|
|
if not task_records:
|
|
continue
|
|
has_records = True
|
|
task_date_label = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-'
|
|
_docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3)
|
|
for record in task_records:
|
|
doc.add_paragraph(f"Datum: {task_date_label}")
|
|
doc.add_paragraph(f"Opis: {record.description or '-'}")
|
|
doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}")
|
|
doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR")
|
|
doc.add_paragraph(f"Kilometraža: {record.mileage if record.mileage is not None else '-'}")
|
|
photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image]
|
|
if photos:
|
|
doc.add_paragraph('Fotografije:')
|
|
for photo in photos:
|
|
image_stream = _compress_image_for_docx(photo.image)
|
|
if image_stream is None:
|
|
continue
|
|
try:
|
|
doc.add_picture(image_stream, width=Cm(16))
|
|
except Exception:
|
|
continue
|
|
photo_caption = str(photo.description or '').strip()
|
|
if photo_caption:
|
|
doc.add_paragraph(photo_caption)
|
|
doc.add_paragraph('')
|
|
if not has_records:
|
|
doc.add_paragraph('Nema povezanih servisnih zapisa za ovaj putni nalog.')
|
|
|
|
buffer = BytesIO()
|
|
doc.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _build_work_order_invoices_docx_bytes(work_order):
|
|
doc = _create_docx_document()
|
|
_docx_add_heading(doc, f'Računi putnog naloga {_work_order_display_code(work_order)}', level=1)
|
|
doc.add_paragraph(f'Datum generiranja: {timezone.localtime(timezone.now()).strftime("%d.%m.%Y %H:%M")}')
|
|
doc.add_paragraph(f'Klijent: {getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"}')
|
|
doc.add_paragraph(f'Dizalica: {work_order.vehicle.registration_number or "-"}')
|
|
|
|
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
|
|
table = doc.add_table(rows=1, cols=5)
|
|
table.rows[0].cells[0].text = 'Naziv računa'
|
|
table.rows[0].cells[1].text = 'Lokacija'
|
|
table.rows[0].cells[2].text = 'Datum'
|
|
table.rows[0].cells[3].text = 'Opis'
|
|
table.rows[0].cells[4].text = 'Prilog'
|
|
|
|
if invoices:
|
|
for invoice in invoices:
|
|
cells = table.add_row().cells
|
|
cells[0].text = str(invoice.naziv_racuna or '-')
|
|
cells[1].text = str(invoice.lokacija or '-')
|
|
cells[2].text = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
|
|
cells[3].text = str(invoice.opis or '-')
|
|
cells[4].text = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '-'
|
|
else:
|
|
cells = table.add_row().cells
|
|
for index in range(5):
|
|
cells[index].text = '-'
|
|
|
|
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
|
|
if additional_costs_table and isinstance(additional_costs_table.data, dict):
|
|
rows = additional_costs_table.data.get('rows', [])
|
|
if isinstance(rows, list) and rows:
|
|
doc.add_paragraph()
|
|
_docx_add_heading(doc, 'Dodatni troškovi', level=2)
|
|
costs_table = doc.add_table(rows=1, cols=3)
|
|
costs_table.rows[0].cells[0].text = 'Naziv'
|
|
costs_table.rows[0].cells[1].text = 'Broj računa'
|
|
costs_table.rows[0].cells[2].text = 'Ukupan iznos'
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
cells = costs_table.add_row().cells
|
|
cells[0].text = str(row.get('naziv') or '-')
|
|
cells[1].text = str(row.get('broj_racuna') or '-')
|
|
cells[2].text = str(row.get('ukupan_iznos') or '-')
|
|
|
|
buffer = BytesIO()
|
|
doc.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _guess_content_type(filename):
|
|
mime, _ = mimetypes.guess_type(str(filename or ''))
|
|
return mime or 'application/octet-stream'
|
|
|
|
|
|
def _file_attachment(file_field, fallback_name):
|
|
if not file_field:
|
|
return None
|
|
file_field.open('rb')
|
|
try:
|
|
content = file_field.read()
|
|
finally:
|
|
file_field.close()
|
|
if not content:
|
|
return None
|
|
filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name
|
|
return (filename, content, _guess_content_type(filename))
|
|
|
|
|
|
def _build_image_attachments_for_work_order(work_order):
|
|
attachments = []
|
|
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
|
for index, photo in enumerate(photos, start=1):
|
|
attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin")
|
|
if attachment:
|
|
attachments.append(attachment)
|
|
|
|
invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
|
for index, invoice in enumerate(invoices, start=1):
|
|
attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin")
|
|
if attachment:
|
|
attachments.append(attachment)
|
|
return attachments
|
|
|
|
|
|
def _parse_month_value(value):
|
|
if value in (None, ''):
|
|
today = timezone.localdate()
|
|
return today.year, today.month, today.strftime('%Y-%m')
|
|
normalized = str(value).strip()
|
|
parts = normalized.split('-')
|
|
if len(parts) != 2:
|
|
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
|
|
try:
|
|
year = int(parts[0])
|
|
month = int(parts[1])
|
|
except ValueError:
|
|
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
|
|
if year < 2000 or year > 2100 or month < 1 or month > 12:
|
|
raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'})
|
|
return year, month, f"{year:04d}-{month:02d}"
|
|
|
|
|
|
def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key):
|
|
from modules.task_management.models import Task
|
|
|
|
tasks = (
|
|
Task.objects
|
|
.filter(
|
|
is_active=True,
|
|
vehicle_id=work_order.vehicle_id,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
)
|
|
.select_related('assigned_to', 'work_order')
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
|
|
csv_buffer = StringIO()
|
|
writer = csv.writer(csv_buffer)
|
|
writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis'])
|
|
for task in tasks:
|
|
assignee_name = _user_display_name(task.assigned_to) or '-'
|
|
work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-'
|
|
writer.writerow([
|
|
task.scheduled_date.isoformat() if task.scheduled_date else '',
|
|
task.title or '',
|
|
task.status or '',
|
|
assignee_name,
|
|
work_order_label,
|
|
task.description or '',
|
|
])
|
|
csv_content = csv_buffer.getvalue().encode('utf-8')
|
|
filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv"
|
|
return (filename, csv_content, 'text/csv'), tasks.count()
|
|
|
|
|
|
def _resolve_service_note_for_email(*, user, note_id, work_order):
|
|
if note_id in (None, ''):
|
|
return None
|
|
note = ServiceContextNote.objects.select_related('created_by', 'recipient', 'work_order').filter(
|
|
pk=note_id,
|
|
is_active=True,
|
|
).first()
|
|
if note is None:
|
|
raise DRFValidationError({'service_note_id': 'Odabrana bilješka ne postoji ili nije aktivna.'})
|
|
if note.work_order_id and str(note.work_order_id) != str(work_order.pk):
|
|
raise DRFValidationError({'service_note_id': 'Bilješka nije povezana s ovim putnim nalogom.'})
|
|
if not (user.is_staff or str(note.created_by_id) == str(user.id) or str(note.recipient_id) == str(user.id)):
|
|
raise DRFValidationError({'service_note_id': 'Nemate dozvolu za korištenje odabrane bilješke.'})
|
|
return note
|
|
|
|
|
|
def _send_email_with_attachments(*, recipients, subject, body, attachments):
|
|
if not recipients:
|
|
raise DRFValidationError({'recipients': 'Nije pronađena nijedna email adresa za slanje.'})
|
|
message = EmailMessage(
|
|
subject=subject,
|
|
body=body,
|
|
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None),
|
|
to=recipients,
|
|
)
|
|
for filename, content, content_type in attachments:
|
|
message.attach(filename, content, content_type)
|
|
message.send(fail_silently=False)
|
|
|
|
|
|
def _dispatch_work_order_email_background(task_kwargs):
|
|
try:
|
|
send_work_order_email_bundle_task.delay(**task_kwargs)
|
|
return 'celery'
|
|
except KombuOperationalError:
|
|
pass
|
|
|
|
def _run_fallback():
|
|
try:
|
|
send_work_order_email_bundle_task.run(**task_kwargs)
|
|
except Exception:
|
|
logger.exception("Greška u fallback background slanju emaila za putni nalog.")
|
|
|
|
thread = threading.Thread(target=_run_fallback, name='work-order-email-fallback', daemon=True)
|
|
thread.start()
|
|
return 'thread'
|
|
|
|
|
|
_MONTH_NAMES_HR = [
|
|
'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj',
|
|
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
|
|
]
|
|
|
|
|
|
def _format_report_hours(value, default='-'):
|
|
if value in (None, ''):
|
|
return default
|
|
try:
|
|
normalized = Decimal(str(value))
|
|
except (InvalidOperation, TypeError, ValueError):
|
|
return str(value)
|
|
text = format(normalized.normalize(), 'f')
|
|
if '.' in text:
|
|
text = text.rstrip('0').rstrip('.')
|
|
return text or '0'
|
|
|
|
|
|
def _build_monthly_servicer_report_rows(user, year, month):
|
|
from calendar import monthrange
|
|
from datetime import date as _date
|
|
from modules.task_management.models import Task as _TaskModel
|
|
|
|
tasks_qs = (
|
|
_TaskModel.objects
|
|
.filter(
|
|
assigned_to=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
)
|
|
.select_related(
|
|
'vehicle',
|
|
'vehicle__client',
|
|
'work_order',
|
|
'work_order__vehicle',
|
|
'work_order__vehicle__client',
|
|
)
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
manual_entries = MonthlyServicerDayEntry.objects.filter(
|
|
user=user,
|
|
is_active=True,
|
|
entry_date__year=year,
|
|
entry_date__month=month,
|
|
).order_by('entry_date')
|
|
|
|
tasks_by_date = OrderedDict()
|
|
for task in tasks_qs:
|
|
tasks_by_date.setdefault(task.scheduled_date, []).append(task)
|
|
|
|
manual_by_date = {
|
|
entry.entry_date: entry
|
|
for entry in manual_entries
|
|
}
|
|
|
|
rows = []
|
|
days_in_month = monthrange(year, month)[1]
|
|
for day in range(1, days_in_month + 1):
|
|
current_date = _date(year, month, day)
|
|
day_tasks = tasks_by_date.get(current_date, [])
|
|
if day_tasks:
|
|
titles = OrderedDict()
|
|
serials = OrderedDict()
|
|
clients = OrderedDict()
|
|
locations = OrderedDict()
|
|
work_orders = OrderedDict()
|
|
start_values = []
|
|
end_values = []
|
|
counted_work_order_ids = set()
|
|
total_hours = 0.0
|
|
|
|
for task in day_tasks:
|
|
title = str(task.title or '').strip()
|
|
if title:
|
|
titles[title] = title
|
|
|
|
vehicle = getattr(task, 'vehicle', None)
|
|
work_order = getattr(task, 'work_order', None)
|
|
if work_order and getattr(work_order, 'vehicle', None):
|
|
vehicle = work_order.vehicle
|
|
|
|
serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
|
|
if serial_value:
|
|
serials[serial_value] = serial_value
|
|
|
|
client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip()
|
|
if client_name:
|
|
clients[client_name] = client_name
|
|
|
|
location = str(getattr(work_order, 'location', '') or '').strip()
|
|
if location:
|
|
locations[location] = location
|
|
|
|
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
|
|
if display_code:
|
|
work_orders[display_code] = display_code
|
|
|
|
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at and str(work_order.id) not in counted_work_order_ids:
|
|
counted_work_order_ids.add(str(work_order.id))
|
|
start_values.append((work_order.id, timezone.localtime(work_order.travel_start_at)))
|
|
end_values.append((work_order.id, timezone.localtime(work_order.travel_end_at)))
|
|
total_hours += (work_order.travel_end_at - work_order.travel_start_at).total_seconds() / 3600.0
|
|
|
|
start_label = '-'
|
|
end_label = '-'
|
|
if start_values:
|
|
start_label = min(value for _, value in start_values).strftime('%H:%M')
|
|
if end_values:
|
|
end_label = max(value for _, value in end_values).strftime('%H:%M')
|
|
|
|
regular_hours = '-'
|
|
overtime_hours = '0'
|
|
if total_hours > 0:
|
|
regular_hours = _format_report_hours(min(total_hours, 8.0), default='0')
|
|
overtime_hours = _format_report_hours(max(total_hours - 8.0, 0.0), default='0')
|
|
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': ' | '.join(titles.values()) or '-',
|
|
'br_dizalice': ', '.join(serials.values()) or '-',
|
|
'komitent': ', '.join(clients.values()) or '-',
|
|
'mjesto_rada': ', '.join(locations.values()) or '-',
|
|
'pocetak_rada': start_label,
|
|
'kraj_rada': end_label,
|
|
'redovan_rad': regular_hours,
|
|
'prekovremeni': overtime_hours,
|
|
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
|
'source': 'task',
|
|
})
|
|
continue
|
|
|
|
manual_entry = manual_by_date.get(current_date)
|
|
if manual_entry:
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': str(manual_entry.description or '').strip() or '-',
|
|
'br_dizalice': '-',
|
|
'komitent': '-',
|
|
'mjesto_rada': str(manual_entry.location or '').strip() or '-',
|
|
'pocetak_rada': manual_entry.start_time.strftime('%H:%M') if manual_entry.start_time else '-',
|
|
'kraj_rada': manual_entry.end_time.strftime('%H:%M') if manual_entry.end_time else '-',
|
|
'redovan_rad': _format_report_hours(manual_entry.regular_hours, default='0'),
|
|
'prekovremeni': _format_report_hours(manual_entry.overtime_hours, default='0'),
|
|
'radni_nalog': '-',
|
|
'source': manual_entry.entry_type,
|
|
})
|
|
continue
|
|
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': '-',
|
|
'br_dizalice': '-',
|
|
'komitent': '-',
|
|
'mjesto_rada': '-',
|
|
'pocetak_rada': '-',
|
|
'kraj_rada': '-',
|
|
'redovan_rad': '-',
|
|
'prekovremeni': '0',
|
|
'radni_nalog': '-',
|
|
'source': 'empty',
|
|
})
|
|
|
|
return rows
|
|
|
|
|
|
class MonthlyServicerDayEntryViewSet(viewsets.ModelViewSet):
|
|
serializer_class = MonthlyServicerDayEntrySerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
|
|
|
|
def get_queryset(self):
|
|
queryset = MonthlyServicerDayEntry.objects.filter(
|
|
user=self.request.user,
|
|
is_active=True,
|
|
).order_by('entry_date')
|
|
year = self.request.query_params.get('year')
|
|
month = self.request.query_params.get('month')
|
|
if year and str(year).isdigit():
|
|
queryset = queryset.filter(entry_date__year=int(year))
|
|
if month and str(month).isdigit():
|
|
queryset = queryset.filter(entry_date__month=int(month))
|
|
return queryset
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
raw_entry_date = request.data.get('entry_date')
|
|
existing = None
|
|
if raw_entry_date:
|
|
existing = self.get_queryset().filter(entry_date=raw_entry_date).first()
|
|
|
|
if existing is not None:
|
|
serializer = self.get_serializer(existing, data=request.data, partial=True)
|
|
serializer.is_valid(raise_exception=True)
|
|
entry = serializer.save()
|
|
return Response(self.get_serializer(entry).data, status=status.HTTP_200_OK)
|
|
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
entry = serializer.save(user=request.user)
|
|
return Response(self.get_serializer(entry).data, status=status.HTTP_201_CREATED)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active', 'updated_at'])
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_servicer_report_docx(request):
|
|
"""Download monthly servicer report as DOCX (landscape table)."""
|
|
from datetime import date as _dt_date
|
|
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)
|
|
|
|
try:
|
|
normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month)
|
|
except ImportError:
|
|
return Response({'detail': 'Task model nije dostupan.'}, status=500)
|
|
|
|
from docx import Document as _DocxDoc
|
|
from docx.shared import Pt, Cm
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.enum.section import WD_ORIENT
|
|
|
|
doc = _DocxDoc()
|
|
for section in doc.sections:
|
|
section.orientation = WD_ORIENT.LANDSCAPE
|
|
section.page_width = Cm(29.7)
|
|
section.page_height = Cm(21.0)
|
|
section.left_margin = Cm(1.5)
|
|
section.right_margin = Cm(1.5)
|
|
section.top_margin = Cm(1.5)
|
|
section.bottom_margin = Cm(1.5)
|
|
|
|
month_label = _MONTH_NAMES_HR[month - 1]
|
|
servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user))
|
|
|
|
p_title = doc.add_paragraph()
|
|
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ SERVISERA')
|
|
r_title.bold = True
|
|
r_title.font.size = Pt(14)
|
|
|
|
p_sub = doc.add_paragraph()
|
|
p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}")
|
|
r_sub.font.size = Pt(11)
|
|
|
|
headers = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA',
|
|
'POČETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG']
|
|
col_widths = [Cm(2.2), Cm(5.5), Cm(2.5), Cm(3.5), Cm(3.5),
|
|
Cm(2.2), Cm(2.2), Cm(2.4), Cm(2.4), Cm(2.4)]
|
|
|
|
table = doc.add_table(rows=1 + len(normalized_rows), cols=len(headers))
|
|
table.style = 'Table Grid'
|
|
|
|
hdr_cells = table.rows[0].cells
|
|
for i, (hdr, w) in enumerate(zip(headers, col_widths)):
|
|
hdr_cells[i].width = w
|
|
hdr_cells[i].text = hdr
|
|
if hdr_cells[i].paragraphs[0].runs:
|
|
run_h = hdr_cells[i].paragraphs[0].runs[0]
|
|
run_h.bold = True
|
|
run_h.font.size = Pt(8)
|
|
|
|
for ri, row_data in enumerate(normalized_rows):
|
|
data_cells = table.rows[ri + 1].cells
|
|
values = [
|
|
row_data['datum'],
|
|
row_data['opis_posla'],
|
|
row_data['br_dizalice'],
|
|
row_data['komitent'],
|
|
row_data['mjesto_rada'],
|
|
row_data['pocetak_rada'],
|
|
row_data['kraj_rada'],
|
|
row_data['redovan_rad'],
|
|
row_data['prekovremeni'],
|
|
row_data['radni_nalog'],
|
|
]
|
|
for ci, (val, w) in enumerate(zip(values, col_widths)):
|
|
data_cells[ci].width = w
|
|
data_cells[ci].text = str(val)
|
|
if data_cells[ci].paragraphs[0].runs:
|
|
data_cells[ci].paragraphs[0].runs[0].font.size = Pt(8)
|
|
|
|
buf = BytesIO()
|
|
doc.save(buf)
|
|
buf.seek(0)
|
|
|
|
month_key = f"{year}-{month:02d}"
|
|
safe_name = re.sub(r'[^\w\-]', '_', servicer_name)
|
|
fname = f"{safe_name}.izvjestaj-servisera.{month_key}.docx"
|
|
resp = HttpResponse(
|
|
buf.read(),
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
resp['Content-Disposition'] = f'attachment; filename="{fname}"'
|
|
return resp
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_costs_report_docx(request):
|
|
"""Download monthly servicer costs (invoices) report as DOCX."""
|
|
from datetime import date as _dt_date
|
|
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)
|
|
|
|
invoices_qs = (
|
|
WorkOrderInvoice.objects
|
|
.filter(
|
|
work_order__work_order_tasks__assigned_to=request.user,
|
|
work_order__work_order_tasks__is_active=True,
|
|
datum__year=year,
|
|
datum__month=month,
|
|
)
|
|
.select_related('work_order')
|
|
.distinct()
|
|
.order_by('datum', 'naziv_racuna')
|
|
)
|
|
|
|
rows = []
|
|
for inv in invoices_qs:
|
|
wo = inv.work_order
|
|
rows.append([
|
|
inv.datum.strftime('%d.%m.%Y') if inv.datum else '-',
|
|
inv.naziv_racuna or '-',
|
|
inv.lokacija or '-',
|
|
inv.opis or '-',
|
|
str(getattr(wo, 'display_code', '') or '').strip() or '-',
|
|
])
|
|
|
|
from docx import Document as _DocxDoc
|
|
from docx.shared import Pt, Cm
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.enum.section import WD_ORIENT
|
|
|
|
doc = _DocxDoc()
|
|
for section in doc.sections:
|
|
section.orientation = WD_ORIENT.LANDSCAPE
|
|
section.page_width = Cm(29.7)
|
|
section.page_height = Cm(21.0)
|
|
section.left_margin = Cm(1.5)
|
|
section.right_margin = Cm(1.5)
|
|
section.top_margin = Cm(1.5)
|
|
section.bottom_margin = Cm(1.5)
|
|
|
|
month_label = _MONTH_NAMES_HR[month - 1]
|
|
servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user))
|
|
|
|
p_title = doc.add_paragraph()
|
|
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ TROŠKOVA SERVISERA')
|
|
r_title.bold = True
|
|
r_title.font.size = Pt(14)
|
|
|
|
p_sub = doc.add_paragraph()
|
|
p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}")
|
|
r_sub.font.size = Pt(11)
|
|
|
|
headers = ['DATUM', 'NAZIV RAČUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG']
|
|
col_widths = [Cm(2.5), Cm(6.0), Cm(4.0), Cm(8.0), Cm(3.0)]
|
|
|
|
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
|
|
table.style = 'Table Grid'
|
|
|
|
hdr_cells = table.rows[0].cells
|
|
for i, (hdr, w) in enumerate(zip(headers, col_widths)):
|
|
hdr_cells[i].width = w
|
|
hdr_cells[i].text = hdr
|
|
if hdr_cells[i].paragraphs[0].runs:
|
|
run_h = hdr_cells[i].paragraphs[0].runs[0]
|
|
run_h.bold = True
|
|
run_h.font.size = Pt(9)
|
|
|
|
for ri, row_data in enumerate(rows):
|
|
data_cells = table.rows[ri + 1].cells
|
|
for ci, (val, w) in enumerate(zip(row_data, col_widths)):
|
|
data_cells[ci].width = w
|
|
data_cells[ci].text = str(val)
|
|
if data_cells[ci].paragraphs[0].runs:
|
|
data_cells[ci].paragraphs[0].runs[0].font.size = Pt(9)
|
|
|
|
buf = BytesIO()
|
|
doc.save(buf)
|
|
buf.seek(0)
|
|
|
|
month_key = f"{year}-{month:02d}"
|
|
safe_name = re.sub(r'[^\w\-]', '_', servicer_name)
|
|
fname = f"{safe_name}.troskovi-servisera.{month_key}.docx"
|
|
resp = HttpResponse(
|
|
buf.read(),
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
resp['Content-Disposition'] = f'attachment; filename="{fname}"'
|
|
return resp
|
|
|
|
|
|
def _monthly_archive_prefix_for_user(user):
|
|
first_name = str(getattr(user, 'first_name', '') or '').strip()
|
|
last_name = str(getattr(user, 'last_name', '') or '').strip()
|
|
if first_name and last_name:
|
|
return f"{first_name[0].upper()}{last_name[0].upper()}"
|
|
return 'MT'
|
|
|
|
|
|
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):
|
|
candidate = entry_name
|
|
entry_path = Path(entry_name)
|
|
stem = entry_path.stem
|
|
suffix = entry_path.suffix
|
|
parent = str(entry_path.parent)
|
|
counter = 2
|
|
while candidate in used_names:
|
|
filename = f"{stem}-{counter}{suffix}"
|
|
candidate = f"{parent}/{filename}" if parent not in ('', '.') else filename
|
|
counter += 1
|
|
used_names.add(candidate)
|
|
return candidate
|
|
|
|
|
|
def _parse_year_month_params(request):
|
|
from datetime import date as _dt_date
|
|
|
|
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(raw_year)
|
|
month = int(raw_month)
|
|
if not (1 <= month <= 12):
|
|
raise ValueError()
|
|
except (ValueError, TypeError):
|
|
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=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
work_order__isnull=False,
|
|
work_order__is_active=True,
|
|
)
|
|
.select_related('work_order', 'vehicle', 'work_hours_table')
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
if not tasks:
|
|
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
|
|
|
used_names = set()
|
|
archive_buffer = BytesIO()
|
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
for task in tasks:
|
|
work_order = task.work_order
|
|
if work_order is None:
|
|
continue
|
|
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
|
base_name = _service_records_docx_filename(work_order, task)
|
|
entry_name = _unique_zip_entry_name(base_name, used_names)
|
|
archive.writestr(entry_name, docx_bytes)
|
|
|
|
archive_content = archive_buffer.getvalue()
|
|
if not archive_content:
|
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
|
return archive_content
|
|
|
|
|
|
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
|
from modules.task_management.models import Task
|
|
|
|
monthly_tasks = (
|
|
Task.objects
|
|
.filter(
|
|
assigned_to=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
work_order__isnull=False,
|
|
work_order__is_active=True,
|
|
)
|
|
.select_related('work_order')
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
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_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:
|
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
|
|
|
used_names = set()
|
|
archive_buffer = BytesIO()
|
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
for work_order in work_orders:
|
|
pdf_bytes = _build_work_order_pdf(work_order)
|
|
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
|
archive.writestr(work_order_pdf_name, pdf_bytes)
|
|
|
|
display_code = _work_order_display_code(work_order)
|
|
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
|
for index, invoice in enumerate(invoices, start=1):
|
|
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
|
|
if not attachment:
|
|
continue
|
|
invoice_filename, content, _content_type = attachment
|
|
archive_path = f"Racuni/{display_code}/{invoice_filename}"
|
|
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
|
|
|
|
archive_content = archive_buffer.getvalue()
|
|
if not archive_content:
|
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
|
return 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):
|
|
"""
|
|
Authenticate user for private Pusher channel.
|
|
Endpoint koji frontend poziva za auth na privatnom kanalu.
|
|
"""
|
|
socket_id = request.data.get('socket_id')
|
|
channel_name = request.data.get('channel_name')
|
|
|
|
if not socket_id or not channel_name:
|
|
return Response({'error': 'Missing socket_id or channel_name'}, status=400)
|
|
|
|
auth_data = PusherService.authenticate_channel(
|
|
socket_id=socket_id,
|
|
channel_name=channel_name,
|
|
user_id=request.user.id
|
|
)
|
|
|
|
if not auth_data:
|
|
return Response({'error': 'Authentication failed'}, status=403)
|
|
|
|
return Response(auth_data)
|
|
|
|
class VehicleViewSet(viewsets.ModelViewSet):
|
|
serializer_class = VehicleSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle')
|
|
|
|
|
|
class CraneViewSet(viewsets.ModelViewSet):
|
|
serializer_class = CraneSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane')
|
|
|
|
|
|
class WorkOrderViewSet(viewsets.ModelViewSet):
|
|
serializer_class = WorkOrderSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _work_orders_queryset_for_user(self.request.user)
|
|
|
|
def perform_update(self, serializer):
|
|
work_order = serializer.save()
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'service_records', 'invoices'])
|
|
|
|
@action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser])
|
|
def images(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
if request.method.lower() == 'post':
|
|
serializer = WorkOrderPhotoSerializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(work_order=work_order, uploaded_by=request.user)
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
image_format, _ = _parse_format(request.query_params.get('fmt'))
|
|
photos_qs = WorkOrderPhoto.objects.filter(
|
|
is_active=True,
|
|
work_order_id=work_order.pk,
|
|
).select_related('uploaded_by').order_by('-created_at')
|
|
|
|
images = []
|
|
for photo in photos_qs:
|
|
file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk})
|
|
original_url = request.build_absolute_uri(file_path)
|
|
optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}")
|
|
images.append({
|
|
"id": photo.pk,
|
|
"work_order_id": photo.work_order_id,
|
|
"description": photo.description,
|
|
"original_url": original_url,
|
|
"optimized_url": optimized_url,
|
|
"created_at": photo.created_at,
|
|
})
|
|
|
|
return Response({
|
|
"work_order_id": work_order.pk,
|
|
"vehicle_id": work_order.vehicle_id,
|
|
"image_quality": quality,
|
|
"image_width": width,
|
|
"image_format": image_format.lower(),
|
|
"images": images,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['get'], url_path=r'images/(?P<photo_id>[^/.]+)/file')
|
|
def image_file(self, request, pk=None, photo_id=None):
|
|
work_order = self.get_object()
|
|
photo = WorkOrderPhoto.objects.filter(
|
|
is_active=True,
|
|
work_order_id=work_order.pk,
|
|
pk=photo_id,
|
|
).first()
|
|
if photo is None or not photo.image:
|
|
raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."})
|
|
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
|
|
|
try:
|
|
photo.image.open('rb')
|
|
with Image.open(photo.image) as source:
|
|
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
|
if width and image.width > width:
|
|
ratio = width / float(image.width)
|
|
target_height = max(1, int(image.height * ratio))
|
|
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
|
|
buffer = BytesIO()
|
|
save_kwargs = {"format": image_format, "optimize": True}
|
|
if image_format in ('WEBP', 'JPEG'):
|
|
save_kwargs["quality"] = quality
|
|
image.save(buffer, **save_kwargs)
|
|
except (UnidentifiedImageError, OSError):
|
|
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
|
finally:
|
|
photo.image.close()
|
|
|
|
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['get'], url_path='pdf')
|
|
def pdf(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
_cleanup_expired_generated_pdfs()
|
|
cached = _get_cached_pdf(work_order, 'work_order')
|
|
if cached:
|
|
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order'))
|
|
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested')
|
|
pdf_bytes = _build_work_order_pdf(work_order)
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
|
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['get'], url_path='docx')
|
|
def docx(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
docx_bytes = _build_work_order_docx_bytes(work_order)
|
|
response = HttpResponse(
|
|
docx_bytes,
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "work_order")}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['post'], url_path='pdf-request')
|
|
def pdf_request(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order')
|
|
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
|
|
|
@action(detail=True, methods=['get'], url_path='service-records-pdf')
|
|
def service_records_pdf(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
task_id = request.query_params.get('task_id')
|
|
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
|
|
|
|
if not selected_task:
|
|
_cleanup_expired_generated_pdfs()
|
|
cached = _get_cached_pdf(work_order, 'service_records')
|
|
if cached:
|
|
return _cached_pdf_file_response(
|
|
cached,
|
|
default_filename=_service_records_pdf_filename(work_order),
|
|
)
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
|
|
|
|
pdf_bytes = _build_work_order_service_records_pdf(work_order, related_tasks=related_tasks)
|
|
|
|
if not selected_task:
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
|
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{_service_records_pdf_filename(work_order, selected_task)}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['get'], url_path='service-records-docx')
|
|
def service_records_docx(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
task_id = request.query_params.get('task_id')
|
|
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
|
|
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=related_tasks)
|
|
response = HttpResponse(
|
|
docx_bytes,
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
response['Content-Disposition'] = f'attachment; filename="{_service_records_docx_filename(work_order, selected_task)}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
|
def service_records_pdf_request(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records')
|
|
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
|
|
|
@action(detail=True, methods=['get'], url_path='invoices-pdf')
|
|
def invoices_pdf(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
_cleanup_expired_generated_pdfs()
|
|
cached = _get_cached_pdf(work_order, 'invoices')
|
|
if cached:
|
|
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices'))
|
|
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested')
|
|
pdf_bytes = _build_work_order_invoices_pdf_bytes(work_order)
|
|
filename = _pdf_filename(work_order, 'invoices')
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
|
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['get'], url_path='invoices-docx')
|
|
def invoices_docx(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
docx_bytes = _build_work_order_invoices_docx_bytes(work_order)
|
|
response = HttpResponse(
|
|
docx_bytes,
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "invoices")}"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['post'], url_path='invoices-pdf-request')
|
|
def invoices_pdf_request(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices')
|
|
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
|
|
|
@action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P<pdf_id>[^/.]+)/download')
|
|
def generated_pdf_download(self, request, pk=None, pdf_id=None):
|
|
work_order = self.get_object()
|
|
_cleanup_expired_generated_pdfs()
|
|
generated_pdf = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
pk=pdf_id,
|
|
status='ready',
|
|
expires_at__gt=timezone.now(),
|
|
).exclude(file='').exclude(file__isnull=True).first()
|
|
if generated_pdf is None:
|
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
|
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
|
|
|
|
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
|
def pdf_preview(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
|
invoice_rows = []
|
|
for invoice in invoices:
|
|
image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None
|
|
datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
|
|
invoice_rows.append(
|
|
f"""
|
|
<tr>
|
|
<td>{escape(invoice.naziv_racuna)}</td>
|
|
<td>{escape(invoice.lokacija or '-')}</td>
|
|
<td>{escape(datum)}</td>
|
|
<td>{escape(invoice.opis or '-')}</td>
|
|
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori dokument</a>' if image_url else '-'}</td>
|
|
</tr>
|
|
"""
|
|
)
|
|
|
|
pdf_url = request.build_absolute_uri(reverse('workorder-pdf', kwargs={'pk': work_order.pk}))
|
|
invoices_pdf_url = request.build_absolute_uri(reverse('workorder-invoices-pdf', kwargs={'pk': work_order.pk}))
|
|
html = f"""
|
|
<!doctype html>
|
|
<html lang="hr">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Računi putnog naloga {_work_order_display_code(work_order)}</title>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 24px; color: #111827; background: #f8fafc; }}
|
|
.card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 16px; }}
|
|
h1 {{ margin-top: 0; font-size: 22px; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
th, td {{ border-bottom: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 14px; vertical-align: top; }}
|
|
th {{ background: #f3f4f6; font-weight: 600; }}
|
|
.actions a {{ display: inline-block; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: 600; }}
|
|
.muted {{ color: #6b7280; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Putni nalog {_work_order_display_code(work_order)} — Računi</h1>
|
|
<p class="muted">Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}</p>
|
|
<div class="actions">
|
|
<a href="{escape(pdf_url)}">Preuzmi PDF putnog naloga</a>
|
|
<a href="{escape(invoices_pdf_url)}" style="margin-left:8px;background:#0ea5e9;">Preuzmi PDF računa</a>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Popis računa</h2>
|
|
{('<table><thead><tr><th>naziv_računa</th><th>lokacija</th><th>datum</th><th>opis</th><th>slika</th></tr></thead><tbody>' + ''.join(invoice_rows) + '</tbody></table>') if invoice_rows else '<p class="muted">Za ovaj putni nalog još nema unesenih računa.</p>'}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HttpResponse(html, content_type='text/html; charset=utf-8')
|
|
|
|
@action(detail=True, methods=['get'], url_path='task-service-context')
|
|
def task_service_context(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
tasks = _work_order_related_tasks_queryset(work_order).prefetch_related(
|
|
'service_records',
|
|
'service_records__photos',
|
|
'service_records__performed_by',
|
|
'service_records__vehicle',
|
|
)
|
|
payload = []
|
|
for task in tasks:
|
|
records_payload = []
|
|
# Filter records by the task's own vehicle (not work_order.vehicle) to support
|
|
# cross-crane work orders where each task may belong to a different crane.
|
|
task_vehicle_id = task.vehicle_id
|
|
for record in task.service_records.filter(is_active=True, vehicle_id=task_vehicle_id).all():
|
|
photos_payload = []
|
|
for photo in record.photos.filter(is_active=True).all():
|
|
if not photo.image:
|
|
continue
|
|
optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
|
|
photos_payload.append({
|
|
'id': photo.pk,
|
|
'image_url': request.build_absolute_uri(photo.image.url),
|
|
'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"),
|
|
'description': photo.description,
|
|
})
|
|
records_payload.append({
|
|
'id': record.pk,
|
|
'service_title': record.service_title,
|
|
'description': record.description,
|
|
'service_date': record.service_date,
|
|
'cost': str(record.cost),
|
|
'mileage': record.mileage,
|
|
'performed_by_name': _user_display_name(record.performed_by),
|
|
'vehicle_registration': getattr(record.vehicle, 'registration_number', None),
|
|
'photos': photos_payload,
|
|
})
|
|
payload.append({
|
|
'id': task.pk,
|
|
'title': task.title,
|
|
'status': task.status,
|
|
'description': task.description,
|
|
'scheduled_date': task.scheduled_date,
|
|
'service_report_note': task.service_report_note or '',
|
|
'assigned_to_name': _user_display_name(task.assigned_to),
|
|
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
|
|
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None),
|
|
'service_records': records_payload,
|
|
})
|
|
|
|
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
|
|
additional_costs_payload = (
|
|
WorkOrderAdditionalCostsTableSerializer(additional_costs_table).data
|
|
if additional_costs_table
|
|
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
|
|
)
|
|
|
|
return Response({
|
|
'work_order_id': work_order.pk,
|
|
'tasks': payload,
|
|
'additional_costs_table': additional_costs_payload,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
|
|
def additional_costs_table(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
table = getattr(work_order, 'additional_costs_table', None)
|
|
|
|
if request.method.lower() == 'get':
|
|
if table:
|
|
return Response(WorkOrderAdditionalCostsTableSerializer(table).data, status=status.HTTP_200_OK)
|
|
return Response(
|
|
{'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
if isinstance(request.data, dict) and 'data' in request.data:
|
|
payload_data = request.data.get('data')
|
|
else:
|
|
payload_data = request.data
|
|
if payload_data == {}:
|
|
payload_data = {'rows': []}
|
|
|
|
serializer = WorkOrderAdditionalCostsTableSerializer(
|
|
table,
|
|
data={'work_order': str(work_order.pk), 'data': payload_data},
|
|
partial=bool(table),
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
instance = serializer.save(work_order=work_order)
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
|
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['post'], url_path='send-email')
|
|
def send_email(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
recipients = _parse_recipients(
|
|
request.data,
|
|
fallback_candidates=[
|
|
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
|
|
getattr(work_order.creator, 'email', None),
|
|
],
|
|
)
|
|
if not recipients:
|
|
raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."})
|
|
|
|
include_work_order_pdf = _parse_boolean_flag(
|
|
request.data.get('include_work_order_pdf'),
|
|
field_name='include_work_order_pdf',
|
|
default=True,
|
|
)
|
|
include_service_records_pdf = _parse_boolean_flag(
|
|
request.data.get('include_service_records_pdf'),
|
|
field_name='include_service_records_pdf',
|
|
default=False,
|
|
)
|
|
include_invoices_pdf = _parse_boolean_flag(
|
|
request.data.get('include_invoices_pdf'),
|
|
field_name='include_invoices_pdf',
|
|
default=False,
|
|
)
|
|
include_images = _parse_boolean_flag(
|
|
request.data.get('include_images'),
|
|
field_name='include_images',
|
|
default=False,
|
|
)
|
|
include_monthly_tasks = _parse_boolean_flag(
|
|
request.data.get('include_monthly_tasks'),
|
|
field_name='include_monthly_tasks',
|
|
default=False,
|
|
)
|
|
selected_note = _resolve_service_note_for_email(
|
|
user=request.user,
|
|
note_id=request.data.get('service_note_id') or request.data.get('note_id'),
|
|
work_order=work_order,
|
|
)
|
|
if not any([
|
|
include_work_order_pdf,
|
|
include_service_records_pdf,
|
|
include_invoices_pdf,
|
|
include_images,
|
|
include_monthly_tasks,
|
|
]):
|
|
raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."})
|
|
month_year = None
|
|
month_number = None
|
|
month_key = ''
|
|
if include_monthly_tasks:
|
|
month_year, month_number, month_key = _parse_month_value(request.data.get('month'))
|
|
|
|
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
|
|
message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga."
|
|
selected_attachment_flags = []
|
|
if include_work_order_pdf:
|
|
selected_attachment_flags.append({'type': 'work_order_pdf'})
|
|
if include_service_records_pdf:
|
|
selected_attachment_flags.append({'type': 'service_records_pdf'})
|
|
if include_invoices_pdf:
|
|
selected_attachment_flags.append({'type': 'invoices_pdf'})
|
|
if include_images:
|
|
selected_attachment_flags.append({'type': 'images'})
|
|
if include_monthly_tasks:
|
|
selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key})
|
|
|
|
dispatch_log = EmailDispatchLog.objects.create(
|
|
dispatch_type='work_order',
|
|
requested_by=request.user,
|
|
work_order=work_order,
|
|
recipients=recipients,
|
|
subject=subject,
|
|
message=message,
|
|
attachments=selected_attachment_flags,
|
|
status='queued',
|
|
metadata={
|
|
'service_note_id': str(selected_note.pk) if selected_note else None,
|
|
},
|
|
)
|
|
|
|
NotificationService.create_notification(
|
|
recipient=request.user,
|
|
title='Slanje emaila u obradi',
|
|
message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.',
|
|
level='info',
|
|
send_email=False,
|
|
metadata={
|
|
'entity_type': 'work_order_email',
|
|
'stage': 'requested',
|
|
'work_order_id': str(work_order.pk),
|
|
'recipients_count': len(recipients),
|
|
},
|
|
)
|
|
dispatch_backend = _dispatch_work_order_email_background(
|
|
{
|
|
'work_order_id': str(work_order.pk),
|
|
'requested_by_id': str(request.user.id),
|
|
'recipients': recipients,
|
|
'subject': subject,
|
|
'message': message,
|
|
'include_work_order_pdf': include_work_order_pdf,
|
|
'include_service_records_pdf': include_service_records_pdf,
|
|
'include_invoices_pdf': include_invoices_pdf,
|
|
'include_images': include_images,
|
|
'include_monthly_tasks': include_monthly_tasks,
|
|
'month_year': month_year,
|
|
'month_number': month_number,
|
|
'month_key': month_key,
|
|
'service_note_id': str(selected_note.pk) if selected_note else None,
|
|
'dispatch_log_id': str(dispatch_log.pk),
|
|
}
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"queued": True,
|
|
"recipients": recipients,
|
|
"work_order_id": str(work_order.pk),
|
|
"dispatch_backend": dispatch_backend,
|
|
},
|
|
status=status.HTTP_202_ACCEPTED,
|
|
)
|
|
|
|
|
|
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
|
serializer_class = VehicleServiceRecordSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
return _service_records_queryset_for_user(user)
|
|
|
|
@action(detail=True, methods=['get'], url_path='pdf')
|
|
def pdf(self, request, pk=None):
|
|
service_record = self.get_object()
|
|
pdf_bytes = _build_service_record_pdf(service_record)
|
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['post'], url_path='send-email')
|
|
def send_email(self, request, pk=None):
|
|
service_record = self.get_object()
|
|
recipient = _first_non_empty(
|
|
request.data.get('recipient'),
|
|
getattr(getattr(service_record.vehicle, 'client', None), 'email', None),
|
|
getattr(service_record.performed_by, 'email', None),
|
|
)
|
|
if not recipient:
|
|
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
|
|
pdf_bytes = _build_service_record_pdf(service_record)
|
|
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
|
|
body = _first_non_empty(request.data.get('message')) or (
|
|
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
|
|
)
|
|
body_with_signature = append_user_signature(body, request.user)
|
|
_send_document_email(
|
|
recipient=recipient,
|
|
subject=subject,
|
|
body=body_with_signature,
|
|
filename=f"{service_record.pk}.service-record.pdf",
|
|
pdf_bytes=pdf_bytes,
|
|
)
|
|
EmailDispatchLog.objects.create(
|
|
dispatch_type='service_record',
|
|
requested_by=request.user,
|
|
service_record=service_record,
|
|
recipients=[recipient],
|
|
subject=subject,
|
|
message=body_with_signature,
|
|
attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}],
|
|
status='sent',
|
|
sent_at=timezone.now(),
|
|
)
|
|
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
|
serializer_class = WorkOrderInvoiceSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
work_order_id = self.request.query_params.get('work_order_id')
|
|
year = self.request.query_params.get('year')
|
|
month = self.request.query_params.get('month')
|
|
qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
|
|
if year and month:
|
|
try:
|
|
year_value = int(year)
|
|
month_value = int(month)
|
|
except (TypeError, ValueError):
|
|
year_value = None
|
|
month_value = None
|
|
if year_value and month_value and 1 <= month_value <= 12:
|
|
qs = qs.filter(
|
|
work_order__work_order_tasks__scheduled_date__year=year_value,
|
|
work_order__work_order_tasks__scheduled_date__month=month_value,
|
|
)
|
|
if user.is_staff:
|
|
if work_order_id:
|
|
qs = qs.filter(work_order_id=work_order_id)
|
|
return qs.distinct()
|
|
allowed_orders = _work_orders_queryset_for_user(user).values('id')
|
|
qs = qs.filter(work_order_id__in=allowed_orders)
|
|
if work_order_id:
|
|
qs = qs.filter(work_order_id=work_order_id)
|
|
return qs.distinct()
|
|
|
|
def get_serializer_context(self):
|
|
context = super().get_serializer_context()
|
|
context['request'] = self.request
|
|
return context
|
|
|
|
@action(detail=True, methods=['get'], url_path='image')
|
|
def image(self, request, pk=None):
|
|
invoice = self.get_object()
|
|
if not invoice.image:
|
|
raise DRFValidationError({"detail": "Datoteka računa nije dostupna."})
|
|
|
|
suffix = Path(invoice.image.name or '').suffix.lower()
|
|
if suffix == '.pdf':
|
|
invoice.image.open('rb')
|
|
try:
|
|
response = HttpResponse(invoice.image.read(), content_type='application/pdf')
|
|
finally:
|
|
invoice.image.close()
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"'
|
|
return response
|
|
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
|
|
|
try:
|
|
invoice.image.open('rb')
|
|
with Image.open(invoice.image) as source:
|
|
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
|
if width and image.width > width:
|
|
ratio = width / float(image.width)
|
|
target_height = max(1, int(image.height * ratio))
|
|
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
|
|
buffer = BytesIO()
|
|
save_kwargs = {"format": image_format, "optimize": True}
|
|
if image_format in ('WEBP', 'JPEG'):
|
|
save_kwargs["quality"] = quality
|
|
image.save(buffer, **save_kwargs)
|
|
except (UnidentifiedImageError, OSError):
|
|
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
|
finally:
|
|
invoice.image.close()
|
|
|
|
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"'
|
|
return response
|
|
|
|
def perform_create(self, serializer):
|
|
work_order = serializer.validated_data.get('work_order')
|
|
if work_order is None:
|
|
raise DRFValidationError({"work_order": "Putni nalog je obavezan."})
|
|
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
|
|
if not allowed:
|
|
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
|
|
invoice = serializer.save(created_by=self.request.user)
|
|
_upsert_additional_cost_row_from_invoice(invoice)
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
|
try:
|
|
process_work_order_invoice_ocr.delay(str(invoice.pk))
|
|
except KombuOperationalError:
|
|
process_work_order_invoice_ocr.apply(args=[str(invoice.pk)])
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active'])
|
|
|
|
|
|
class ServiceContextNoteViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _service_context_notes_queryset_for_user(self.request.user)
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == 'create':
|
|
return ServiceContextNoteCreateSerializer
|
|
return ServiceContextNoteSerializer
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
payload = serializer.validated_data
|
|
user = request.user
|
|
audience = payload.get('audience', 'self')
|
|
target_users = payload.get('target_users') or []
|
|
note_text = payload.get('note')
|
|
note_date = payload.get('note_date')
|
|
work_order = payload.get('work_order')
|
|
task = payload.get('task')
|
|
is_supervisor = _is_supervisor_user(user)
|
|
can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False))
|
|
|
|
if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists():
|
|
raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'})
|
|
if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id):
|
|
raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'})
|
|
|
|
recipients = []
|
|
if audience == 'self':
|
|
recipients = [user]
|
|
elif audience == 'member':
|
|
recipients = list(target_users)
|
|
else:
|
|
if not can_manage_recipients:
|
|
raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'})
|
|
recipients = list(_active_team_members_queryset())
|
|
if not recipients:
|
|
raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'})
|
|
|
|
created_notes = []
|
|
seen = OrderedDict()
|
|
for recipient in recipients:
|
|
seen[str(recipient.id)] = recipient
|
|
with transaction.atomic():
|
|
for recipient in seen.values():
|
|
note = ServiceContextNote.objects.create(
|
|
created_by=user,
|
|
recipient=recipient,
|
|
audience_source=audience,
|
|
work_order=work_order,
|
|
task=task,
|
|
note=note_text,
|
|
note_date=note_date,
|
|
)
|
|
created_notes.append(note)
|
|
NotificationService.create_notification(
|
|
recipient=recipient,
|
|
title='Nova bilješka servisnog konteksta',
|
|
message=note_text[:255],
|
|
level='info',
|
|
metadata={
|
|
'entity_type': 'service_note',
|
|
'service_note_id': str(note.id),
|
|
'work_order_id': str(work_order.id) if work_order else None,
|
|
'task_id': str(task.id) if task else None,
|
|
'section': 'dashboard',
|
|
'note_date': note_date.isoformat() if note_date else None,
|
|
},
|
|
send_email=False,
|
|
)
|
|
|
|
response_serializer = ServiceContextNoteSerializer(
|
|
_service_context_notes_queryset_for_user(user)[:20],
|
|
many=True,
|
|
)
|
|
return Response(
|
|
{
|
|
'created_count': len(created_notes),
|
|
'notes': response_serializer.data,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='close')
|
|
def close(self, request, pk=None):
|
|
note = self.get_object()
|
|
if note.is_closed:
|
|
serializer = ServiceContextNoteSerializer(note)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
note.is_closed = True
|
|
note.closed_at = timezone.now()
|
|
note.save(update_fields=['is_closed', 'closed_at', 'updated_at'])
|
|
serializer = ServiceContextNoteSerializer(note)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
|
"""
|
|
List all notifications for the authenticated user and provide an action
|
|
to mark a notification as read.
|
|
"""
|
|
serializer_class = VehicleNotificationSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
# Prikaži samo aktivne notifikacije za trenutnog korisnika
|
|
user = self.request.user
|
|
return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at')
|
|
|
|
@action(detail=True, methods=['post'], url_path='mark-read')
|
|
def mark_read(self, request, pk=None):
|
|
"""
|
|
Označi notifikaciju kao pročitanu.
|
|
URL: POST /api/fleet/notifications/{pk}/mark-read/
|
|
"""
|
|
notif = self.get_object()
|
|
# sigurnosna provjera: dozvoljeno samo primatelju
|
|
if notif.recipient != request.user:
|
|
raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.")
|
|
|
|
notif.is_read = True
|
|
notif.save(update_fields=['is_read'])
|
|
|
|
serializer = self.get_serializer(notif)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=False, methods=['post'], url_path='mark-all-read')
|
|
def mark_all_read(self, request):
|
|
"""
|
|
Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane.
|
|
URL: POST /api/fleet/notifications/mark-all-read/
|
|
"""
|
|
user = request.user
|
|
qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False)
|
|
updated_count = qs.update(is_read=True)
|
|
return Response({"marked": updated_count}, status=status.HTTP_200_OK)
|
|
|
|
class VehicleServicePhotoViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
ViewSet za upravljanje fotografijama servisnih zapisa.
|
|
Delegira sve operacije u VehicleServicePhotoService.
|
|
Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka.
|
|
"""
|
|
queryset = VehicleServicePhoto.objects.filter(is_active=True)
|
|
serializer_class = VehicleServicePhotoSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
"""Filtriraj po service_record ID-u ako je proslijeđen u query parametrima."""
|
|
service_record_id = self.request.query_params.get('service_record_id')
|
|
user = self.request.user
|
|
qs = VehicleServicePhoto.objects.filter(is_active=True)
|
|
if not user.is_staff:
|
|
qs = qs.filter(service_record__performed_by=user)
|
|
if service_record_id:
|
|
qs = qs.filter(service_record_id=service_record_id)
|
|
return qs
|
|
|
|
@action(detail=True, methods=['get'], url_path='optimized')
|
|
def optimized(self, request, pk=None):
|
|
photo = self.get_object()
|
|
if not photo.image:
|
|
raise DRFValidationError({"detail": "Fotografija nije dostupna."})
|
|
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
|
|
|
try:
|
|
photo.image.open('rb')
|
|
with Image.open(photo.image) as source:
|
|
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
|
if width and image.width > width:
|
|
ratio = width / float(image.width)
|
|
target_height = max(1, int(image.height * ratio))
|
|
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
|
|
buffer = BytesIO()
|
|
save_kwargs = {"format": image_format, "optimize": True}
|
|
if image_format in ('WEBP', 'JPEG'):
|
|
save_kwargs["quality"] = quality
|
|
image.save(buffer, **save_kwargs)
|
|
except (UnidentifiedImageError, OSError):
|
|
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
|
finally:
|
|
photo.image.close()
|
|
|
|
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"'
|
|
return response
|
|
|
|
def perform_create(self, serializer):
|
|
"""
|
|
Delegiraj stvaranje u VehicleServicePhotoService.
|
|
serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika.
|
|
"""
|
|
try:
|
|
data = serializer.validated_data
|
|
if not _can_access_service_record(self.request.user, data.get('service_record')):
|
|
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
|
|
photo = VehicleServicePhotoService.upload_photo(
|
|
data=data,
|
|
uploaded_by=self.request.user
|
|
)
|
|
serializer.instance = photo
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
def perform_update(self, serializer):
|
|
"""
|
|
Delegiraj ažuriranje u VehicleServicePhotoService.
|
|
"""
|
|
try:
|
|
instance = serializer.instance
|
|
data = serializer.validated_data
|
|
photo = VehicleServicePhotoService.update_photo(
|
|
instance=instance,
|
|
data=data,
|
|
user=self.request.user
|
|
)
|
|
serializer.instance = photo
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
def perform_destroy(self, instance):
|
|
"""
|
|
Delegiraj soft-delete u VehicleServicePhotoService.
|
|
"""
|
|
try:
|
|
VehicleServicePhotoService.delete_photo(
|
|
instance=instance,
|
|
user=self.request.user
|
|
)
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet):
|
|
queryset = VehicleServiceAttachment.objects.filter(is_active=True)
|
|
serializer_class = VehicleServiceAttachmentSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
service_record_id = self.request.query_params.get('service_record_id')
|
|
qs = VehicleServiceAttachment.objects.filter(is_active=True)
|
|
if not user.is_staff:
|
|
qs = qs.filter(service_record__performed_by=user)
|
|
if service_record_id:
|
|
qs = qs.filter(service_record_id=service_record_id)
|
|
return qs
|
|
|
|
def perform_create(self, serializer):
|
|
if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')):
|
|
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
|
|
serializer.save(uploaded_by=self.request.user)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active']) |