# /backend/modules/fleet/views.py from io import BytesIO from io import StringIO import base64 import csv import mimetypes import threading 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, WorkOrder, WorkOrderPhoto, GeneratedWorkOrderPdf, WorkOrderInvoice, WorkOrderAdditionalCostsTable, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, ) from .serializers import ( CraneSerializer, VehicleSerializer, WorkOrderSerializer, WorkOrderInvoiceSerializer, WorkOrderAdditionalCostsTableSerializer, WorkOrderPhotoSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, ServiceContextNoteSerializer, ServiceContextNoteCreateSerializer, 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, cleanup_expired_generated_pdfs_task, process_work_order_invoice_ocr, send_work_order_email_bundle_task, ) register_unicode_fonts() User = get_user_model() 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 def _work_order_display_code(work_order): normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper() if normalized: return normalized return 'NALOG' 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 _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
{company_name}", size=8.8), _paragraph("N A L O G
ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1), _paragraph( f"Mjesto i datum:
{place_label}    {_fmt_date(now_local.date())}
" f"Broj naloga:
{display_code}", 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:
{creator_name}

Prebivalište:
{creator_residence}", size=8.7), _paragraph(f"Zanimanje:
{creator_occupation}

Na radnom mjestu:
{creator_work_position}", 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:
{attachments_text}", size=8.5), _paragraph( f"Ukupno za isplatu: {_fmt_eur(grand_total)}
" f"Isplaćeno: {_fmt_eur(0)}
" f"Ostaje za isplatu: {_fmt_eur(grand_total)}", 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): 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 service_rows = list( VehicleServiceRecord.objects.filter( is_active=True, vehicle_id=work_order.vehicle_id, ) .select_related('performed_by', 'task') .prefetch_related('photos') .order_by('service_date', 'created_at') ) related_tasks = list(_work_order_related_tasks_queryset(work_order)) task_titles = [row.task.title for row in service_rows if getattr(row, 'task', None) and row.task.title] notes_text = ' | '.join(task_titles[:2]) if task_titles else (work_order.notes or '') 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 = ImageReader(photo.image) 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 info_rows = [ ["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_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 = ImageReader(photo.image) 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 _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' @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) @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[^/.]+)/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=['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() _cleanup_expired_generated_pdfs() cached = _get_cached_pdf(work_order, 'service_records') if cached: return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records')) _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) _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="{_pdf_filename(work_order, "service_records")}"' 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=['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[^/.]+)/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""" {escape(invoice.naziv_racuna)} {escape(invoice.lokacija or '-')} {escape(datum)} {escape(invoice.opis or '-')} {f'Otvori dokument' if image_url else '-'} """ ) 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""" Računi putnog naloga {_work_order_display_code(work_order)}

Putni nalog {_work_order_display_code(work_order)} — Računi

Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}

Popis računa

{('' + ''.join(invoice_rows) + '
naziv_računalokacijadatumopisslika
') if invoice_rows else '

Za ovaj putni nalog još nema unesenih računa.

'}
""" 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 = [] for record in task.service_records.filter(is_active=True, vehicle_id=work_order.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, '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') qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True) if user.is_staff: if work_order_id: qs = qs.filter(work_order_id=work_order_id) return qs 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 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'])