# /backend/modules/fleet/views.py from io import BytesIO import base64 from PIL import Image, UnidentifiedImageError from django.conf import settings from django.core.mail import EmailMessage from django.http import HttpResponse from django.urls import reverse from django.utils import timezone from django.utils.html import escape from django.db.models import Q import os 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 reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž) _RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts') pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf'))) pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf'))) pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf'))) from rest_framework import viewsets, permissions, status 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, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, ) from .serializers import ( CraneSerializer, VehicleSerializer, WorkOrderSerializer, WorkOrderInvoiceSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, VehicleServicePhotoSerializer, VehicleServiceAttachmentSerializer, ) from infrastructure.pusher_service import PusherService from .services import ( VehicleService, VehicleServicePhotoService, NotificationService, ) from .tasks import build_work_order_invoices_pdf_task 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 _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').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 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() 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 _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) grand_total = daily_total + transport_total place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb' invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna] attachments_text = ', '.join(invoice_names) if invoice_names else '-' 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:
{work_order.pk}", 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"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}", ], ["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"], [place_label or '-', work_order.location or '-', work_order.servicer_vehicle_make_model or '-', 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"]] for inv in invoices[:5]: additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"]) 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'), ('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 '-' model_str = ' '.join(filter(None, [vehicle.make, 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 = str(work_order.pk) generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y') HEADER_H = 86 FOOTER_H = 72 MARGIN = 28 DAY_MAP = { 0: 'PO', 1: 'UT', 2: 'SR', 3: 'ČET', 4: 'PET', 5: 'SUB', 6: 'NED', } def _fmt_date(value): if not value: return '-' return value.strftime('%d.%m.%Y') def _fmt_time(value): if not value: return '-' return value.astimezone(timezone.get_current_timezone()).strftime('%H:%M') 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)) 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)) travel_hours = _hours_between(work_order.travel_start_at, work_order.travel_end_at) travel_km = work_order.distance if work_order.distance is not None else 0 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): w, h = A4 page_num = self._current_page self.setStrokeColor(colors.black) self.setLineWidth(0.7) self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0) self.setFont("Vera-Bold", 11) self.drawString(MARGIN + 6, h - 22, client_name[:44]) self.drawCentredString(w / 2, h - 22, "Izvještaj servisera") self.setFont("Vera", 9) self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}") self.setLineWidth(0.5) self.line(MARGIN, h - 36, w - MARGIN, h - 36) labels = [ "Model:", "Serijski broj:", "Radni sati nadogradnje:", "Radni sati podvozja:", "Km:", "Broj naloga:", ] values = [ model_str, serial_str, upgrade_hours_str, chassis_hours_str, mileage_str, nalog_str, ] col_w = (w - 2 * MARGIN) / len(labels) self.setFont("Vera", 7.2) for i, (lbl, val) in enumerate(zip(labels, values)): x = MARGIN + i * col_w + 3 self.drawString(x, h - 49, lbl) value_text = str(val or '-') value_font_size = 9 if i == len(labels) - 1: value_font_size = 7 max_value_width = col_w - 8 while ( self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width and value_font_size > 5.5 ): value_font_size -= 0.3 if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width: trimmed = value_text while ( len(trimmed) > 3 and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width ): trimmed = trimmed[:-1] value_text = f"{trimmed}..." else: value_text = value_text[:24] self.setFont("Vera-Bold", value_font_size) self.drawString(x, h - 61, value_text) self.setFont("Vera", 7.2) if i > 0: self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4) footer_y = FOOTER_H self.setLineWidth(0.5) self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0) sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"] sig_w = (w - 2 * MARGIN) / 4 self.setFont("Vera", 7.2) for i, lbl in enumerate(sig_labels): x = MARGIN + i * sig_w self.drawString(x + 4, footer_y + 45, lbl) if i > 0: self.line(x, footer_y + 42, x, footer_y + 60) self.setFont("Vera", 6.5) self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu") self.drawCentredString( w / 2, footer_y + 14, "Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH", ) 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 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] if work_order.travel_start_at or work_order.travel_end_at: day_idx = work_order.travel_start_at.weekday() if work_order.travel_start_at else None table3_rows.append([ DAY_MAP.get(day_idx, '-'), _fmt_date(work_order.travel_start_at.date() if work_order.travel_start_at else work_order.date), "-", f"{_fmt_time(work_order.travel_start_at)} - {_fmt_time(work_order.travel_end_at)}", "-", "-", str(travel_hours).replace('.', ','), work_order.location or '-', str(travel_km or '-'), ]) for record in service_rows[:6]: day_idx = record.service_date.weekday() if record.service_date else None table3_rows.append([ DAY_MAP.get(day_idx, '-'), _fmt_date(record.service_date), "-", "-", "-", "-", "-", getattr(record.vehicle, 'registration_number', '-') or '-', str(record.mileage or '-'), ]) if len(table3_rows) == 1: table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "-", "-"]) total_hours_travel = str(travel_hours).replace('.', ',') for _ in range(6): table3_rows.append(["", "", "", "", "", "", "", "", ""]) table3_rows.append(["UKUPNO", "", "", "", "", "", total_hours_travel, "", str(travel_km or 0)]) 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), ]), ) 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 taskova 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"Task #{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 _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 _notify_pdf_request(*, user, work_order, doc_type, stage): 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" if stage == 'requested': title = f"Zahtjev za PDF {doc_label}" message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}." level = "info" else: title = f"PDF spreman ({doc_label})" message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}." level = "success" NotificationService.create_notification( recipient=user, title=title, message=message, level=level, send_email=False, 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, }, ) @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'], url_path='images') def images(self, request, pk=None): work_order = self.get_object() service_record_id = request.query_params.get('service_record_id') 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')) work_order_task_ids = list( work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True) ) if not work_order_task_ids: return Response({ "work_order_id": work_order.pk, "vehicle_id": work_order.vehicle_id, "service_record_id": service_record_id or None, "images": [], }, status=status.HTTP_200_OK) photos_qs = VehicleServicePhoto.objects.filter( is_active=True, service_record__is_active=True, service_record__vehicle_id=work_order.vehicle_id, service_record__task_id__in=work_order_task_ids, ).select_related('service_record', 'uploaded_by').order_by('-created_at') if not request.user.is_staff: photos_qs = photos_qs.filter(service_record__performed_by=request.user) if service_record_id: record = _service_records_queryset_for_user(request.user).filter( pk=service_record_id, vehicle_id=work_order.vehicle_id, task_id__in=work_order_task_ids, ).first() if record is None: raise DRFValidationError({ "service_record_id": "Servisni zapis nije pronađen ili ne pripada ovom nalogu." }) photos_qs = photos_qs.filter(service_record_id=record.pk) images = [] for photo in photos_qs: optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk}) optimized_url = request.build_absolute_uri( f"{optimized_path}?w={width}&q={quality}&fmt={image_format.lower()}" ) original_url = request.build_absolute_uri(photo.image.url) if photo.image else None images.append({ "id": photo.pk, "service_record_id": photo.service_record_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, "service_record_id": service_record_id or None, "images": images, }, status=status.HTTP_200_OK) @action(detail=True, methods=['get'], url_path='pdf') def pdf(self, request, pk=None): work_order = self.get_object() _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="{work_order.pk}.work-order.pdf"' return response @action(detail=True, methods=['get'], url_path='service-records-pdf') def service_records_pdf(self, request, pk=None): work_order = self.get_object() _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="{work_order.pk}.work-order-service-records.pdf"' return response @action(detail=True, methods=['get'], url_path='invoices-pdf') def invoices_pdf(self, request, pk=None): work_order = self.get_object() _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested') 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: pdf_bytes = base64.b64decode(pdf_b64) except (ValueError, TypeError): raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) filename = payload.get('filename') or f"{work_order.pk}.work-order-invoices.pdf" _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='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(invoice.image.url) 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 sliku' 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 WO-{work_order.pk}

Putni nalog WO-{work_order.pk} — Računi

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

Preuzmi PDF putnog naloga Preuzmi PDF računa

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), 'service_records': records_payload, }) return Response({ 'work_order_id': work_order.pk, 'tasks': payload, }, 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() recipient = _first_non_empty( request.data.get('recipient'), getattr(getattr(work_order.vehicle, 'client', None), 'email', None), getattr(work_order.creator, 'email', None), ) if not recipient: raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) pdf_bytes = _build_work_order_pdf(work_order) subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog WO-{work_order.pk}" body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga." _send_document_email( recipient=recipient, subject=subject, body=body, filename=f"{work_order.pk}.work-order.pdf", pdf_bytes=pdf_bytes, ) return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) 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() rows = [ ("ID", str(service_record.pk)), ("Datum", str(service_record.service_date)), ("Dizalica", service_record.vehicle.registration_number), ("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email), ("Kilometraza", str(service_record.mileage)), ("Sljedeci servis", str(service_record.next_service_due_at or '-')), ("Trosak", str(service_record.cost)), ("Opis", service_record.description or '-'), ("Dijelovi", service_record.parts or '-'), ] pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) 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."}) rows = [ ("ID", str(service_record.pk)), ("Datum", str(service_record.service_date)), ("Dizalica", service_record.vehicle.registration_number), ("Kilometraza", str(service_record.mileage)), ("Trosak", str(service_record.cost)), ("Opis", service_record.description or '-'), ("Dijelovi", service_record.parts or '-'), ] pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) 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')}." ) _send_document_email( recipient=recipient, subject=subject, body=body, filename=f"{service_record.pk}.service-record.pdf", pdf_bytes=pdf_bytes, ) 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 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."}) serializer.save(created_by=self.request.user) def perform_destroy(self, instance): instance.is_active = False instance.save(update_fields=['is_active']) 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'])