# /backend/modules/fleet/views.py from io import BytesIO from io import StringIO import base64 import csv import mimetypes import threading import zipfile import re from collections import OrderedDict from datetime import datetime, 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, Max from django.db import transaction from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.lib.utils import ImageReader from reportlab.lib.styles import getSampleStyleSheet from reportlab.pdfgen import canvas from reportlab.platypus import Table, TableStyle, Paragraph from .pdf_layout import register_unicode_fonts, draw_standard_header_footer from .email_utils import append_user_signature from rest_framework import viewsets, permissions, status, mixins from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError from rest_framework.parsers import MultiPartParser, FormParser from celery.exceptions import TimeoutError as CeleryTimeoutError from kombu.exceptions import OperationalError as KombuOperationalError from .models import ( Crane, Vehicle, VehicleNotification, EmailDispatchLog, ServiceContextNote, MonthlyServicerDayEntry, WorkOrder, WorkOrderPhoto, GeneratedWorkOrderPdf, GeneratedFleetArchive, WorkOrderInvoice, WorkOrderAdditionalCostsTable, WorkOrderTravelExpensesTable, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, ) from .serializers import ( CraneSerializer, VehicleSerializer, WorkOrderSerializer, WorkOrderInvoiceSerializer, WorkOrderAdditionalCostsTableSerializer, WorkOrderTravelExpensesTableSerializer, WorkOrderPhotoSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, ServiceContextNoteSerializer, ServiceContextNoteCreateSerializer, MonthlyServicerDayEntrySerializer, VehicleServicePhotoSerializer, VehicleServiceAttachmentSerializer, ) from infrastructure.pusher_service import PusherService from .services import ( VehicleService, VehicleServicePhotoService, NotificationService, ) from .tasks import ( build_work_order_invoices_pdf_task, build_work_order_pdf_cached_task, build_monthly_archive_cached_task, cleanup_expired_generated_archives_task, cleanup_expired_generated_pdfs_task, process_work_order_invoice_ocr, send_work_order_email_bundle_task, ) register_unicode_fonts() User = get_user_model() DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates' SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx' def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) if asset_type: queryset = queryset.filter(asset_type=asset_type) if user.is_staff or getattr(user, 'is_serviser', False): return queryset return queryset def _service_records_queryset_for_user(user): queryset = ( VehicleServiceRecord.objects .select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle') .prefetch_related('photos', 'attachments') .all() ) if user.is_staff: return queryset return queryset.filter(performed_by=user) def _work_orders_queryset_for_user(user): queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all() if user.is_staff: return queryset if getattr(user, 'is_serviser', False): return queryset.filter( Q(creator=user) | Q(vehicle__assigned_servicer=user) ).distinct() return queryset.filter(creator=user) def _is_supervisor_user(user): return bool(getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))) def _active_team_members_queryset(): return User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email') def _service_context_notes_queryset_for_user(user): return ( ServiceContextNote.objects .select_related('created_by', 'recipient', 'work_order', 'task') .filter(is_active=True, recipient=user, is_closed=False) .order_by('note_date', '-created_at') ) def _work_order_related_tasks_queryset(work_order): from modules.task_management.models import Task direct_task_ids = list( work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True) ) inferred_task_ids = list( VehicleServiceRecord.objects.filter( is_active=True, vehicle_id=work_order.vehicle_id, task_id__isnull=False, task__is_active=True, task__work_order=work_order, ).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 _parse_report_date_value(value): text = str(value or '').strip() if not text: return None for fmt in ('%Y-%m-%d', '%d.%m.%Y.', '%d.%m.%Y'): try: return datetime.strptime(text, fmt).date() except ValueError: continue return None def _parse_report_time_value(value): text = str(value or '').strip() if not text: return None for fmt in ('%H:%M', '%H:%M:%S'): try: return datetime.strptime(text, fmt).time() except ValueError: continue return None def _parse_report_decimal_value(value): if value in (None, ''): return Decimal('0.00') try: return Decimal(str(value).strip().replace(',', '.')) except (InvalidOperation, TypeError, ValueError): return Decimal('0.00') def _calculate_daily_quantity_from_hours(total_hours): numeric = Decimal(str(total_hours)) if total_hours not in (None, '') else Decimal('0.00') if numeric <= 0: return 0.0 if numeric < Decimal('8'): return 0.0 if numeric < Decimal('12'): return 0.5 return 1.0 def _task_work_hours_entries(task): table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) rows = table_data.get('rows', []) if isinstance(table_data, dict) else [] if not isinstance(rows, list): rows = [] entries = [] work_order = getattr(task, 'work_order', None) fallback_date = getattr(task, 'scheduled_date', None) task_vehicle = getattr(task, 'vehicle', None) task_client = getattr(task_vehicle, 'client', None) for raw_row in rows: if not isinstance(raw_row, dict): continue row_date = _parse_report_date_value(raw_row.get('date')) or fallback_date if row_date is None: continue start_text = str(raw_row.get('work_time_from') or raw_row.get('travel_time_from') or '').strip() end_text = str(raw_row.get('work_time_to') or raw_row.get('travel_time_to') or '').strip() start_time = _parse_report_time_value(start_text) end_time = _parse_report_time_value(end_text) start_dt = datetime.combine(row_date, start_time) if start_time else None end_dt = datetime.combine(row_date, end_time) if end_time else None if start_dt and end_dt and end_dt <= start_dt: end_dt += timedelta(days=1) work_hours = _parse_report_decimal_value(raw_row.get('work_hours')) travel_hours = _parse_report_decimal_value(raw_row.get('travel_hours')) entries.append({ 'date': row_date, 'date_label': row_date.strftime('%d.%m.%Y.'), 'start_dt': start_dt, 'end_dt': end_dt, 'work_hours': work_hours, 'travel_hours': travel_hours, 'total_hours': work_hours + travel_hours, 'title': str(getattr(task, 'title', '') or '').strip(), 'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(), 'client': str(getattr(task_client, 'name', '') or '').strip(), 'location': str(getattr(work_order, 'location', '') or '').strip(), 'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(), }) if entries: return entries if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at: start_dt = timezone.localtime(work_order.travel_start_at) end_dt = timezone.localtime(work_order.travel_end_at) fallback_date = fallback_date or start_dt.date() travel_hours = Decimal(str((end_dt - start_dt).total_seconds() / 3600.0)).quantize(Decimal('0.01')) return [{ 'date': fallback_date, 'date_label': fallback_date.strftime('%d.%m.%Y.') if fallback_date else '-', 'start_dt': start_dt, 'end_dt': end_dt, 'work_hours': Decimal('0.00'), 'travel_hours': travel_hours, 'total_hours': travel_hours, 'title': str(getattr(task, 'title', '') or '').strip(), 'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(), 'client': str(getattr(task_client, 'name', '') or '').strip(), 'location': str(getattr(work_order, 'location', '') or '').strip(), 'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(), }] return [] return [] def _format_decimal_display(value, *, default='0'): if value in (None, ''): return default try: normalized = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return str(value) text = format(normalized.normalize(), 'f') if '.' in text: text = text.rstrip('0').rstrip('.') return text.replace('.', ',') or default def _format_decimal_fixed(value, *, default='0.00', places=2): if value in (None, ''): return default try: normalized = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return str(value) quantizer = Decimal('1').scaleb(-places) return format(normalized.quantize(quantizer), 'f') def _normalize_datetime_for_compare(value): if not isinstance(value, datetime): return value if timezone.is_naive(value): return timezone.make_aware(value, timezone.get_current_timezone()) return timezone.localtime(value) def _work_order_travel_expenses_context(work_order): related_tasks = list(_work_order_related_tasks_queryset(work_order)) trip_entries = [] for task in related_tasks: trip_entries.extend(_task_work_hours_entries(task)) travel_start = getattr(work_order, 'travel_start_at', None) travel_end = getattr(work_order, 'travel_end_at', None) trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None) trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None) total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00')) if trip_entries: entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')] start_candidates = [ _normalize_datetime_for_compare(entry.get('start_dt')) for entry in trip_entries if entry.get('start_dt') ] end_candidates = [ _normalize_datetime_for_compare(entry.get('end_dt')) for entry in trip_entries if entry.get('end_dt') ] if entry_dates: trip_start_date = min(entry_dates) trip_end_date = max(entry_dates) if start_candidates: travel_start = min(start_candidates) if end_candidates: travel_end = max(end_candidates) table = getattr(work_order, 'travel_expenses_table', None) if table: broj_sati = Decimal(str(table.broj_sati or '0')) kolicina_dnevnica = Decimal(str(table.kolicina_dnevnica or '0')) iznos_dnevnica = Decimal(str(table.iznos_dnevnica or '0')) daily_rate_country = str(table.daily_rate_country or WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY) total_for_payout = Decimal(str(table.total_for_payout or '0')).quantize(Decimal('0.01')) else: broj_sati = total_hours.quantize(Decimal('0.01')) kolicina_dnevnica = Decimal(str(_calculate_daily_quantity_from_hours(broj_sati))) daily_rate_country = WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY iznos_dnevnica = WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT total_for_payout = (kolicina_dnevnica * iznos_dnevnica).quantize(Decimal('0.01')) return { 'related_tasks': related_tasks, 'trip_entries': trip_entries, 'travel_start': travel_start, 'travel_end': travel_end, 'trip_start_date': trip_start_date, 'trip_end_date': trip_end_date, 'broj_sati': broj_sati, 'kolicina_dnevnica': kolicina_dnevnica, 'iznos_dnevnica': iznos_dnevnica, 'daily_rate_country': daily_rate_country, 'total_for_payout': total_for_payout, } def _can_access_service_record(user, service_record): service_record_id = getattr(service_record, 'pk', service_record) return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists() def _parse_positive_int(value, *, field_name, default, min_value, max_value): if value in (None, ''): return default try: parsed = int(value) except (TypeError, ValueError): raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."}) if parsed < min_value or parsed > max_value: raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."}) return parsed def _parse_format(value): selected = (value or 'webp').lower() supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'} if selected not in supported: raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."}) extension = 'jpg' if selected in ('jpeg', 'jpg') else selected return supported[selected], extension GENERATED_PDF_TTL_HOURS = 24 GENERATED_ARCHIVE_TTL_DAYS = 7 def _work_order_display_code(work_order): normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper() if normalized: return normalized return 'NALOG' def _sanitize_task_title_for_filename(value): normalized = re.sub(r'\s+', '_', str(value or '').strip()) normalized = re.sub(r'[^A-Za-z0-9_-]+', '', normalized) normalized = normalized.strip('._-') return normalized or 'servisni_zapis' def _service_task_filename_label(task): return f"SN-{_sanitize_task_title_for_filename(getattr(task, 'title', ''))}" def _pdf_filename(work_order, pdf_type): display_code = _work_order_display_code(work_order) if pdf_type == 'invoices': return f"{display_code}.work-order-invoices.pdf" if pdf_type == 'service_records': return f"{display_code}.work-order-service-records.pdf" return f"{display_code}.work-order.pdf" def _docx_filename(work_order, doc_type): display_code = _work_order_display_code(work_order) if doc_type == 'invoices': return f"{display_code}.work-order-invoices.docx" if doc_type == 'service_records': return f"{display_code}.work-order-service-records.docx" return f"{display_code}.work-order.docx" def _service_records_pdf_filename(work_order, task=None): if not task: return _pdf_filename(work_order, 'service_records') display_code = _work_order_display_code(work_order) return f"{display_code}.{_service_task_filename_label(task)}.pdf" def _service_records_docx_filename(work_order, task=None): if not task: return _docx_filename(work_order, 'service_records') display_code = _work_order_display_code(work_order) return f"{display_code}.{_service_task_filename_label(task)}.docx" def _resolve_service_report_tasks(work_order, task_id): queryset = _work_order_related_tasks_queryset(work_order) if not task_id: return list(queryset), None selected_task = queryset.filter(pk=task_id).first() if not selected_task: raise DRFValidationError({ 'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.' }) return [selected_task], selected_task def _compress_image_for_pdf(image_field, max_width=1280, quality=75): """ Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna. """ try: image_field.open('rb') with Image.open(image_field) as src: img = src.convert('RGB') if img.width > max_width: ratio = max_width / float(img.width) new_h = max(1, int(img.height * ratio)) img = img.resize((max_width, new_h), Image.LANCZOS) buf = BytesIO() img.save(buf, format='JPEG', quality=quality, optimize=True) buf.seek(0) return ImageReader(buf) except Exception: return None finally: try: image_field.close() except Exception: pass def _compress_image_for_docx(image_field, max_width=1600, quality=80): """ Pripremi sliku za python-docx kao JPEG stream razumne veličine. """ try: image_field.open('rb') with Image.open(image_field) as src: img = src.convert('RGB') if img.width > max_width: ratio = max_width / float(img.width) new_h = max(1, int(img.height * ratio)) img = img.resize((max_width, new_h), Image.LANCZOS) buf = BytesIO() img.save(buf, format='JPEG', quality=quality, optimize=True) buf.seek(0) return buf except Exception: return None finally: try: image_field.close() except Exception: pass def _get_cached_pdf(work_order, pdf_type): now = timezone.now() expected_filename = _pdf_filename(work_order, pdf_type) candidates = list( GeneratedWorkOrderPdf.objects .filter( is_active=True, work_order=work_order, pdf_type=pdf_type, status='ready', expires_at__gt=now, ) .exclude(file='') .exclude(file__isnull=True) .order_by('-created_at') ) for candidate in candidates: if str(candidate.filename or '').strip() == expected_filename: file_name = getattr(candidate.file, 'name', '') storage = getattr(candidate.file, 'storage', None) if file_name and storage: try: if storage.exists(file_name): return candidate except (FileNotFoundError, OSError, ValueError): pass candidate.file = None candidate.is_active = False candidate.status = 'failed' candidate.error_message = 'PDF datoteka nije dostupna.' candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) return None def _mark_generated_pdf_failed(generated_pdf, error_message): generated_pdf.file = None generated_pdf.is_active = False generated_pdf.status = 'failed' generated_pdf.error_message = error_message generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at']) 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.file = None item.is_active = False item.status = 'failed' item.error_message = 'PDF cache istekao.' item.save(update_fields=['file', '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.file = None cached.is_active = False cached.status = 'failed' cached.error_message = 'PDF cache invalidiran zbog promjene podataka.' cached.save(update_fields=['file', '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): try: generated_pdf.file.open('rb') except (FileNotFoundError, OSError, ValueError): _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) 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_context = _work_order_travel_expenses_context(work_order) related_tasks = travel_context['related_tasks'] trip_entries = travel_context['trip_entries'] travel_start = travel_context['travel_start'] travel_end = travel_context['travel_end'] trip_start_date = travel_context['trip_start_date'] trip_end_date = travel_context['trip_end_date'] travel_hours = travel_context['broj_sati'] daily_qty = travel_context['kolicina_dnevnica'] daily_rate = travel_context['iznos_dnevnica'] daily_total = travel_context['total_for_payout'] transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost) 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 + 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(trip_start_date or work_order.date), _fmt_time(travel_start), _fmt_date(trip_end_date or work_order.date), _fmt_time(travel_end), _format_decimal_display(travel_hours), _format_decimal_display(daily_qty), _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, related_tasks=None): HEADER_H = 86 FOOTER_H = 72 MARGIN = 28 if related_tasks is None: related_tasks = list(_work_order_related_tasks_queryset(work_order)) else: related_tasks = list(related_tasks) context = _build_service_report_context(work_order, related_tasks) client_name = context['client_name'] manufacturer_str = context['manufacturer'] model_str = context['model_name'] serial_str = context['serial'] upgrade_hours_str = context['upgrade_hours'] chassis_hours_str = context['chassis_hours'] mileage_str = context['mileage'] nalog_str = context['work_order_display_code'] generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y') # Collect service records for all task vehicles (supports cross-crane work orders) task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id}) service_rows = list( VehicleServiceRecord.objects.filter( is_active=True, vehicle_id__in=task_vehicle_ids if task_vehicle_ids else [work_order.vehicle_id], ) .select_related('performed_by', 'task') .prefetch_related('photos') .order_by('service_date', 'created_at') ) # Per-task notes from Task.service_report_note (edited via "Uredi tekst napomene") task_note_parts = [ str(task.service_report_note or '').strip() for task in related_tasks if str(task.service_report_note or '').strip() ] notes_text = "\n".join(task_note_parts) if task_note_parts else '-' buffer = BytesIO() class PageCanvas(canvas.Canvas): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._current_page = 0 def showPage(self): self._current_page += 1 self._draw_header_footer() super().showPage() def save(self): self._current_page += 1 self._draw_header_footer() super().save() def _draw_header_footer(self): page_num = self._current_page draw_standard_header_footer( self, page_num=page_num, client_name=client_name, manufacturer=manufacturer_str, model=model_str, serial=serial_str, upgrade_hours=upgrade_hours_str, chassis_hours=chassis_hours_str, mileage=mileage_str, work_order_number=nalog_str, generated_date=generated_date_str, report_title="Izvještaj servisera", page_size=A4, margin=MARGIN, header_h=HEADER_H, footer_h=FOOTER_H, ) pdf = PageCanvas(buffer, pagesize=A4) page_w, page_h = A4 content_top = page_h - HEADER_H - 10 content_bottom = FOOTER_H + 52 content_width = page_w - 2 * MARGIN def new_page(): pdf.showPage() return content_top def draw_table(y, data, col_widths, style, row_heights=None): table = Table(data, colWidths=col_widths, rowHeights=row_heights) table.setStyle(style) _, h = table.wrap(content_width, page_h) if y - h < content_bottom: y = new_page() table.drawOn(pdf, MARGIN, y - h) return y - h - 10 paragraph_styles = getSampleStyleSheet() description_style = paragraph_styles['BodyText'].clone('service-description') description_style.fontName = 'Vera' description_style.fontSize = 8.2 description_style.leading = 10.4 def _normalize_hours_table_row(row): if not isinstance(row, dict): return None return { 'day': str(row.get('day', '') or '').strip() or '-', 'date': str(row.get('date', '') or '').strip() or '-', 'work_time': " - ".join( [ str(row.get('work_time_from', '') or '').strip() or '-', str(row.get('work_time_to', '') or '').strip() or '-', ] ), 'travel_time': " - ".join( [ str(row.get('travel_time_from', '') or '').strip() or '-', str(row.get('travel_time_to', '') or '').strip() or '-', ] ), 'break_hours': str(row.get('break_hours', '') or '').strip() or '-', 'work_hours': str(row.get('work_hours', '') or '').strip() or '-', 'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-', 'places': "\n".join([ f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}", f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}", ]), 'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-', } def _parse_decimal(value): if value in (None, ''): return 0.0 try: return float(str(value).replace(',', '.')) except (TypeError, ValueError): return 0.0 def _format_decimal(value): return f"{float(value):.1f}".replace('.', ',') normalized_hours_rows = [] for task in related_tasks: table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) if not isinstance(table_data, dict): continue rows = table_data.get('rows', []) if not isinstance(rows, list): continue for raw_row in rows: normalized = _normalize_hours_table_row(raw_row) if normalized: normalized_hours_rows.append(normalized) y = content_top table1 = [ ["Naziv Tvrtke:", client_name, "Lokacija intervencije:", context['location']], ["Serviser:", context['servicer_name'], "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), ]), ) table2 = [ ["Prijevozno sredstvo:", "Registracija:", "Broj narudžbe klijenta:", "Posao završen"], [context['servicer_vehicle_make_model'], context['servicer_vehicle_registration'], "-", context['completion_label']], ] y = draw_table( y, table2, [152, 100, 147, content_width - 399], TableStyle([ ('FONTNAME', (0, 0), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 8), ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('LEFTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3), ]), row_heights=[17, 17], ) table3_headers = [ "Dan", "Datum", "Vrijeme rada\nod-do", "Vrijeme putovanja\nod-do", "Pauza\nh", "Sati\nrada", "Sati\nputa", "Mjesto polaska / dolaska", "Kilometri\nvozila", ] table3_rows = [table3_headers] total_hours_travel = 0.0 total_vehicle_km = 0.0 if normalized_hours_rows: for row in normalized_hours_rows: table3_rows.append([ row['day'], row['date'], row['work_time'], row['travel_time'], row['break_hours'], row['work_hours'], row['travel_hours'], row['places'], row['vehicle_km'], ]) total_hours_travel += _parse_decimal(row['travel_hours']) total_vehicle_km += _parse_decimal(row['vehicle_km']) else: for _ in range(12): table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"]) table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')]) y = draw_table( y, table3_rows, [28, 54, 72, 72, 36, 40, 40, 97, content_width - 439], TableStyle([ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTNAME', (0, 1), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 7.8), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('ALIGN', (0, 0), (0, -1), 'CENTER'), ('ALIGN', (1, 0), (6, -1), 'CENTER'), ('SPAN', (0, -1), (5, -1)), ('ALIGN', (0, -1), (5, -1), 'LEFT'), ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'), ('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'), ('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'), ('LEFTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, -1), (5, -1), 12), ('VALIGN', (7, 1), (7, -2), 'TOP'), ('ALIGN', (7, 1), (7, -2), 'LEFT'), ]), ) if y < content_bottom + 24: y = new_page() pdf.setFont("Vera", 9) pdf.drawString(MARGIN, y, f"Napomene: {notes_text or '-'}") # Sljedeća stranica: servisni zapisi. y = new_page() y -= 14 pdf.setFont("Vera-Bold", 12) pdf.drawString(MARGIN, y, "Servisni zapisi") y -= 18 if not related_tasks: pdf.setFont("Vera", 9) pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.") else: for task in related_tasks: task_records = [ row for row in service_rows if getattr(row, 'task_id', None) == task.id ] if not task_records: continue if y < content_bottom + 60: y = new_page() pdf.setFont("Vera-Bold", 10) pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95]) y -= 14 for record in task_records: if y < content_bottom + 64: y = new_page() description_table = [[Paragraph(record.description or '-', description_style)]] y = draw_table( y, [["Servisni opis"], *description_table], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, -1), 'Vera'), ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) parts_table = [[Paragraph(record.parts or '-', description_style)]] y = draw_table( y, [["Korišteni dijelovi"], *parts_table], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, -1), 'Vera'), ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image] if not photos: continue if y < content_bottom + 30: y = new_page() y -= 8 pdf.setFont("Vera", 8) pdf.drawString(MARGIN, y, "Fotografije:") y -= 10 image_gap = 10 max_image_height = 110 image_width = (content_width - image_gap) / 2 for index in range(0, len(photos), 2): row_photos = photos[index:index + 2] prepared = [] for photo in row_photos: try: image_reader = _compress_image_for_pdf(photo.image) if image_reader is None: continue source_w, source_h = image_reader.getSize() if not source_w or not source_h: continue scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w))) prepared.append((photo, image_reader, scaled_h)) except Exception: continue if not prepared: continue caption_height = 12 row_height = max(item[2] for item in prepared) + caption_height if y - row_height < content_bottom: y = new_page() base_y = y - caption_height for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared): image_x = MARGIN + photo_index * (image_width + image_gap) image_y = base_y - scaled_h pdf.drawImage( image_reader, image_x, image_y, width=image_width, height=scaled_h, preserveAspectRatio=True, anchor='c', mask='auto', ) pdf.setFont("Vera", 7) caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}" pdf.drawString(image_x, image_y - 10, caption[:52]) y = base_y - max(item[2] for item in prepared) - 14 pdf.save() return buffer.getvalue() def _build_service_record_pdf(service_record): vehicle = service_record.vehicle task = getattr(service_record, 'task', None) work_order = getattr(task, 'work_order', None) if task else None client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-' manufacturer_str = str(vehicle.make or '-') model_str = str(vehicle.model or '-') serial_str = str(vehicle.crane_serial_number or '-') upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-') chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-') mileage_str = str(vehicle.current_mileage or '-') nalog_str = _work_order_display_code(work_order) if work_order else '-' generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y') HEADER_H = 86 FOOTER_H = 72 MARGIN = 28 def _fmt_date(value): if not value: return '-' return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value) buffer = BytesIO() class PageCanvas(canvas.Canvas): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._current_page = 0 def showPage(self): self._current_page += 1 self._draw_header_footer() super().showPage() def save(self): self._current_page += 1 self._draw_header_footer() super().save() def _draw_header_footer(self): page_num = self._current_page draw_standard_header_footer( self, page_num=page_num, client_name=client_name, manufacturer=manufacturer_str, model=model_str, serial=serial_str, upgrade_hours=upgrade_hours_str, chassis_hours=chassis_hours_str, mileage=mileage_str, work_order_number=nalog_str, generated_date=generated_date_str, report_title="Izvještaj servisera", page_size=A4, margin=MARGIN, header_h=HEADER_H, footer_h=FOOTER_H, ) pdf = PageCanvas(buffer, pagesize=A4) page_w, page_h = A4 content_top = page_h - HEADER_H - 10 content_bottom = FOOTER_H + 52 content_width = page_w - 2 * MARGIN def new_page(): pdf.showPage() return content_top def draw_table(y, data, col_widths, style, row_heights=None): table = Table(data, colWidths=col_widths, rowHeights=row_heights) table.setStyle(style) _, h = table.wrap(content_width, page_h) if y - h < content_bottom: y = new_page() table.drawOn(pdf, MARGIN, y - h) return y - h - 10 paragraph_styles = getSampleStyleSheet() description_style = paragraph_styles['BodyText'].clone('service-record-description') description_style.fontName = 'Vera' description_style.fontSize = 8.2 description_style.leading = 10.4 y = content_top - 6 y -= 6 task_scheduled_date = getattr(task, 'scheduled_date', None) if task else None info_rows = [ ["ID", str(service_record.pk), "Datum", _fmt_date(task_scheduled_date)], ["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'], ["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'], ["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')], ["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')], ] y = draw_table( y, info_rows, [78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)], TableStyle([ ('FONTNAME', (0, 0), (-1, -1), 'Vera'), ('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'), ('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 8.5), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) y = draw_table( y, [["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTNAME', (0, 1), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) y = draw_table( y, [["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTNAME', (0, 1), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) attachments = list(service_record.attachments.filter(is_active=True).all()) attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file] y = draw_table( y, [["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTNAME', (0, 1), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image] if photos: if y < content_bottom + 30: y = new_page() y -= 8 pdf.setFont("Vera", 8) pdf.drawString(MARGIN, y, "Fotografije:") y -= 10 image_gap = 10 max_image_height = 110 image_width = (content_width - image_gap) / 2 for index in range(0, len(photos), 2): row_photos = photos[index:index + 2] prepared = [] for photo in row_photos: try: image_reader = _compress_image_for_pdf(photo.image) if image_reader is None: continue source_w, source_h = image_reader.getSize() if not source_w or not source_h: continue scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w))) prepared.append((photo, image_reader, scaled_h)) except (OSError, ValueError, TypeError, UnidentifiedImageError): continue if not prepared: continue caption_height = 12 row_height = max(item[2] for item in prepared) + caption_height if y - row_height < content_bottom: y = new_page() base_y = y - caption_height for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared): image_x = MARGIN + photo_index * (image_width + image_gap) image_y = base_y - scaled_h pdf.drawImage( image_reader, image_x, image_y, width=image_width, height=scaled_h, preserveAspectRatio=True, anchor='c', mask='auto', ) pdf.setFont("Vera", 7) caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}" pdf.drawString(image_x, image_y - 10, caption[:52]) y = base_y - max(item[2] for item in prepared) - 14 else: y = draw_table( y, [["Fotografije"], ["-"]], [content_width], TableStyle([ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), ('FONTNAME', (0, 1), (-1, -1), 'Vera'), ('FONTSIZE', (0, 0), (-1, -1), 8.2), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ]), ) pdf.save() return buffer.getvalue() def _first_non_empty(*values): for value in values: if isinstance(value, str) and value.strip(): return value.strip() return None def _user_display_name(user): if not user: return None full_name = user.get_full_name() if full_name: return full_name if getattr(user, 'email', None): return user.email if getattr(user, 'username', None): return user.username return str(getattr(user, 'pk', '')) def _send_document_email(*, recipient, subject, body, filename, pdf_bytes): if not recipient: raise DRFValidationError({"recipient": "Email primatelja je obavezan."}) message = EmailMessage( subject=subject, body=body, from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None), to=[recipient], ) message.attach(filename, pdf_bytes, 'application/pdf') message.send(fail_silently=False) def _parse_boolean_flag(value, *, field_name, default=False): if value is None: return default if isinstance(value, bool): return value normalized = str(value).strip().lower() if normalized in {'1', 'true', 'yes', 'da', 'on'}: return True if normalized in {'0', 'false', 'no', 'ne', 'off'}: return False raise DRFValidationError({field_name: f"Neispravna boolean vrijednost za {field_name}."}) def _parse_recipients(payload, *, fallback_candidates): raw_recipients = payload.get('recipients') if raw_recipients is None: single = payload.get('recipient') raw_recipients = [single] if single is not None else [] elif isinstance(raw_recipients, str): raw_recipients = [raw_recipients] elif not isinstance(raw_recipients, list): raise DRFValidationError({'recipients': 'Polje recipients mora biti lista email adresa.'}) prepared = [] for item in raw_recipients: if not isinstance(item, str): raise DRFValidationError({'recipients': 'Svaki primatelj mora biti email adresa.'}) chunks = [chunk.strip() for chunk in item.split(',')] prepared.extend(chunk for chunk in chunks if chunk) if not prepared: prepared = [candidate for candidate in fallback_candidates if isinstance(candidate, str) and candidate.strip()] unique = OrderedDict() for email in prepared: normalized = email.strip() if not normalized: continue try: validate_email(normalized) except DjangoValidationError: raise DRFValidationError({'recipients': f'Neispravna email adresa: {normalized}.'}) unique[normalized.lower()] = normalized return list(unique.values()) def _build_work_order_invoices_pdf_bytes(work_order): try: task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk)) payload = task_result.get(timeout=45) except KombuOperationalError: payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get() except CeleryTimeoutError: raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."}) if not isinstance(payload, dict) or payload.get('error'): raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."}) pdf_b64 = payload.get('pdf_base64') if not pdf_b64: raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."}) try: return base64.b64decode(pdf_b64) except (ValueError, TypeError): raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) def _create_docx_document(template_name=None): try: from docx import Document except ModuleNotFoundError: raise DRFValidationError({"detail": "DOCX generiranje nije dostupno: nedostaje python-docx paket."}) if not template_name: document = Document() _remove_docx_edit_restrictions(document) return document template_path = DOCX_TEMPLATE_DIR / template_name if not template_path.exists(): raise DRFValidationError({"detail": f"DOCX template nije pronađen: {template_name}."}) document = Document(str(template_path)) _remove_docx_edit_restrictions(document) return document def _remove_docx_edit_restrictions(document): try: settings_element = document.settings.element except AttributeError: return document protected_tags = ( '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}documentProtection', '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}writeProtection', '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}readOnlyRecommended', ) for tag_name in protected_tags: for element in list(settings_element.findall(tag_name)): settings_element.remove(element) return document def _set_docx_cell_text(table, row_index, col_index, value): row_count = len(getattr(table, 'rows', [])) if row_count <= row_index: raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) col_count = len(getattr(table.rows[row_index], 'cells', [])) if col_count <= col_index: raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) table.rows[row_index].cells[col_index].text = str(value or '-') def _docx_add_heading(document, text, level=1): style_name = f'Heading {int(level)}' try: document.styles[style_name] return document.add_paragraph(str(text or ''), style=style_name) except KeyError: paragraph = document.add_paragraph() run = paragraph.add_run(str(text or '')) run.bold = True return paragraph def _normalize_whitespace(value): return " ".join(str(value or '').split()).strip() def _docx_remove_rows_after(table, keep_rows=1): rows = list(getattr(table, 'rows', [])) for row in rows[keep_rows:]: table._tbl.remove(row._tr) def _docx_remove_table(table): parent = table._tbl.getparent() if parent is not None: parent.remove(table._tbl) def _docx_move_table_after_paragraph_text(document, table, marker_text): if not marker_text: return False marker_norm = _normalize_whitespace(marker_text).lower() if not marker_norm: return False body = document._body._element children = list(body) table_element = table._tbl paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' for index, element in enumerate(children): if element.tag != paragraph_namespace: continue paragraph_text = ''.join(node.text or '' for node in element.iter(text_namespace)) if marker_norm not in _normalize_whitespace(paragraph_text).lower(): continue if table_element in children: body.remove(table_element) children = list(body) index = children.index(element) body.insert(index + 1, table_element) return True return False def _extract_unique_parts_entries(records): unique = OrderedDict() for record in records: raw_parts = _normalize_whitespace(getattr(record, 'parts', None)) if not raw_parts or raw_parts == '-': continue key = raw_parts.lower() if key in unique: continue serial = '-' description = raw_parts match = re.match(r'^\[([^\]]+)\]\s*(.*)$', raw_parts) if match: serial = _normalize_whitespace(match.group(1)) or '-' description = _normalize_whitespace(match.group(2)) or '-' related_task = getattr(record, 'task', None) changed_at_value = '-' if related_task and getattr(related_task, 'scheduled_date', None): changed_at_value = related_task.scheduled_date.strftime('%d.%m.%Y') unique[key] = { 'serial': serial, 'description': description, 'note': '-', 'changed_at': changed_at_value, } return list(unique.values()) def _docx_cleanup_service_report_template(document): body = document._body._element children = list(body) paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' seen_hours_heading = False seen_note = False remove_tail = False for element in children: tag = element.tag if tag == paragraph_namespace: text = ''.join(node.text or '' for node in element.iter(text_namespace)) normalized = _normalize_whitespace(text) normalized_lower = normalized.lower() if 'servisni zapisi' in normalized_lower: remove_tail = True if remove_tail: body.remove(element) continue if normalized_lower == 'tablica radnih sati': if seen_hours_heading: body.remove(element) continue seen_hours_heading = True if normalized_lower.startswith('napomene:'): if seen_note: body.remove(element) continue seen_note = True elif remove_tail and not tag.endswith('sectPr'): body.remove(element) def _docx_remove_empty_page_break_paragraphs(document): body = document._body._element paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' break_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}br' text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' wordprocessing_namespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' for element in list(body): if element.tag != paragraph_namespace: continue text = ''.join(node.text or '' for node in element.iter(text_namespace)).strip() if text: continue has_page_break = any( br.tag == break_namespace and br.get(f'{{{wordprocessing_namespace}}}type') == 'page' for br in element.iter(break_namespace) ) if has_page_break: body.remove(element) def _build_service_report_context(work_order, related_tasks): primary_task = related_tasks[0] if len(related_tasks) == 1 else None context_work_order = getattr(primary_task, 'work_order', None) if primary_task else None if context_work_order is None: context_work_order = work_order context_vehicle = getattr(primary_task, 'vehicle', None) if primary_task else None if context_vehicle is None: context_vehicle = getattr(context_work_order, 'vehicle', None) or work_order.vehicle manufacturer = str(getattr(context_vehicle, 'make', '') or '').strip() model_name = str(getattr(context_vehicle, 'model', '') or '').strip() model_parts = [manufacturer, model_name] model_label = " ".join(part for part in model_parts if part) or '-' return { 'work_order': context_work_order, 'vehicle': context_vehicle, 'client_name': getattr(getattr(context_vehicle, 'client', None), 'name', None) or '-', 'location': getattr(context_work_order, 'location', None) or '-', 'servicer_name': _user_display_name(getattr(context_work_order, 'creator', None)) or '-', 'servicer_vehicle_make_model': getattr(context_work_order, 'servicer_vehicle_make_model', None) or '-', 'servicer_vehicle_registration': getattr(context_work_order, 'servicer_vehicle_registration', None) or '-', 'completion_label': "Da ☒ Ne ☐" if getattr(context_work_order, 'status', None) == 'closed' else "Da ☐ Ne ☒", 'manufacturer': manufacturer or '-', 'model_name': model_name or '-', 'model': model_label, 'serial': str(getattr(context_vehicle, 'crane_serial_number', None) or '-'), 'upgrade_hours': str(getattr(context_vehicle, 'superstructure_working_hours', None) or '-'), 'chassis_hours': str(getattr(context_vehicle, 'chassis_working_hours', None) or '-'), 'mileage': str(getattr(context_vehicle, 'current_mileage', None) or '-'), 'work_order_display_code': _work_order_display_code(context_work_order), } def _populate_service_report_docx_header(document, context): for section in getattr(document, 'sections', []): header = getattr(section, 'header', None) if header is None: continue header_tables = list(getattr(header, 'tables', [])) if len(header_tables) >= 2: table_meta = header_tables[0] _set_docx_cell_text(table_meta, 1, 0, context['model']) _set_docx_cell_text(table_meta, 1, 2, context['serial']) _set_docx_cell_text(table_meta, 1, 4, context['upgrade_hours']) _set_docx_cell_text(table_meta, 1, 5, context['chassis_hours']) _set_docx_cell_text(table_meta, 1, 6, context['mileage']) _set_docx_cell_text(table_meta, 1, 8, context['work_order_display_code']) table_title = header_tables[1] _set_docx_cell_text(table_title, 0, 0, context['client_name']) _set_docx_cell_text(table_title, 0, 1, 'Izvještaj servisera') def _build_work_order_docx_bytes(work_order): vehicle = work_order.vehicle creator = work_order.creator doc = _create_docx_document() _docx_add_heading(doc, f'Putni nalog {_work_order_display_code(work_order)}', level=1) rows = [ ('Datum naloga', str(work_order.date or '-')), ('Serviser', _user_display_name(creator) or getattr(creator, 'email', '-') or '-'), ('Klijent', getattr(getattr(vehicle, 'client', None), 'name', None) or '-'), ('Dizalica', " ".join(part for part in [vehicle.registration_number, vehicle.make, vehicle.model] if part) or '-'), ('Lokacija', work_order.location or '-'), ('Ishodište', work_order.origin_location or '-'), ('Svrha', work_order.purpose or '-'), ('Status', work_order.status or '-'), ('Napomene', work_order.notes or '-'), ] table = doc.add_table(rows=1, cols=2) table.rows[0].cells[0].text = 'Polje' table.rows[0].cells[1].text = 'Vrijednost' for label, value in rows: row_cells = table.add_row().cells row_cells[0].text = str(label) row_cells[1].text = str(value) additional_costs_table = getattr(work_order, 'additional_costs_table', None) if additional_costs_table and isinstance(additional_costs_table.data, dict): additional_rows = additional_costs_table.data.get('rows', []) if isinstance(additional_rows, list) and additional_rows: doc.add_paragraph() _docx_add_heading(doc, 'Dodatni troškovi', level=2) costs_table = doc.add_table(rows=1, cols=4) costs_table.rows[0].cells[0].text = 'Naziv' costs_table.rows[0].cells[1].text = 'Broj računa' costs_table.rows[0].cells[2].text = 'Ukupan iznos' costs_table.rows[0].cells[3].text = 'Prilog' for entry in additional_rows: if not isinstance(entry, dict): continue row_cells = costs_table.add_row().cells row_cells[0].text = str(entry.get('naziv') or '-') row_cells[1].text = str(entry.get('broj_racuna') or '-') row_cells[2].text = str(entry.get('ukupan_iznos') or '-') row_cells[3].text = str(entry.get('prilog') or '-') buffer = BytesIO() doc.save(buffer) return buffer.getvalue() def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None): from docx.shared import Cm doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME) if related_tasks is None: related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table')) else: related_tasks = list(related_tasks) context = _build_service_report_context(work_order, related_tasks) _populate_service_report_docx_header(doc, context) normalized_rows = [] for task in related_tasks: table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) if not isinstance(table_data, dict): continue rows = table_data.get('rows', []) if not isinstance(rows, list): continue for row in rows: if not isinstance(row, dict): continue normalized_rows.append({ 'day': str(row.get('day', '') or '').strip() or '-', 'date': str(row.get('date', '') or '').strip() or '-', 'work_time': " - ".join([ str(row.get('work_time_from', '') or '').strip() or '-', str(row.get('work_time_to', '') or '').strip() or '-', ]), 'travel_time': " - ".join([ str(row.get('travel_time_from', '') or '').strip() or '-', str(row.get('travel_time_to', '') or '').strip() or '-', ]), 'break_hours': str(row.get('break_hours', '') or '').strip() or '-', 'work_hours': str(row.get('work_hours', '') or '').strip() or '-', 'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-', 'places': "\n".join([ f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}", f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}", ]), 'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-', }) # Collect service records per task, filtering by each task's own vehicle # (supports cross-crane work orders where each task may have a different crane). task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id}) service_rows = list( VehicleServiceRecord.objects.filter( is_active=True, vehicle_id__in=task_vehicle_ids, ) .select_related('performed_by', 'task') .prefetch_related('photos') .order_by('service_date', 'created_at') ) records_by_task = { task.id: [row for row in service_rows if getattr(row, 'task_id', None) == task.id] for task in related_tasks } # Build notes only from per-task service_report_note (edited via "Uredi tekst napomene") task_note_parts = [ str(task.service_report_note or '').strip() for task in related_tasks if str(task.service_report_note or '').strip() ] notes_text = "\n".join(task_note_parts) if task_note_parts else '-' if len(doc.tables) >= 3: info_table = doc.tables[0] _set_docx_cell_text(info_table, 1, 0, context['client_name']) _set_docx_cell_text(info_table, 1, 1, context['location']) _set_docx_cell_text(info_table, 3, 0, context['servicer_name']) if len(info_table.rows[3].cells) > 1: _set_docx_cell_text(info_table, 3, 1, '-') transport_table = doc.tables[1] _set_docx_cell_text(transport_table, 1, 0, context['servicer_vehicle_make_model']) _set_docx_cell_text(transport_table, 1, 1, context['servicer_vehicle_registration']) _set_docx_cell_text(transport_table, 1, 2, '-') _set_docx_cell_text(transport_table, 1, 4, context['completion_label']) all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])] summary_table = doc.tables[2] _docx_remove_rows_after(summary_table, keep_rows=1) unique_parts_entries = _extract_unique_parts_entries(all_task_records) summary_rows = unique_parts_entries or [{ 'serial': '-', 'description': '-', 'note': '-', 'changed_at': '-', }] for index, entry in enumerate(summary_rows): target_row = summary_table.rows[1] if index == 0 and len(summary_table.rows) > 1 else summary_table.add_row() target_row.cells[0].text = entry['serial'] target_row.cells[1].text = entry['description'] target_row.cells[2].text = entry['note'] target_row.cells[3].text = entry['changed_at'] if len(doc.tables) > 3: _docx_remove_table(doc.tables[3]) else: raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."}) for paragraph in doc.paragraphs: paragraph_text = str(getattr(paragraph, 'text', '') or '').strip() if paragraph_text.lower().startswith('napomene:'): paragraph.text = f"Napomene: {notes_text}" break hours_table = None for table in doc.tables: if not table.rows: continue headers = [_normalize_whitespace(cell.text).lower() for cell in table.rows[0].cells] if len(headers) >= 9 and headers[0] == 'dan' and headers[1] == 'datum': hours_table = table break if hours_table is None: _docx_add_heading(doc, 'Tablica radnih sati', level=2) hours_table = doc.add_table(rows=1, cols=9) headers = ['Dan', 'Datum', 'Vrijeme rada', 'Vrijeme putovanja', 'Pauza h', 'Sati rada', 'Sati puta', 'Polazak / dolazak', 'Km vozila'] for index, header in enumerate(headers): hours_table.rows[0].cells[index].text = header _docx_remove_rows_after(hours_table, keep_rows=1) if normalized_rows: for row in normalized_rows: cells = hours_table.add_row().cells cells[0].text = row['day'] cells[1].text = row['date'] cells[2].text = row['work_time'] cells[3].text = row['travel_time'] cells[4].text = row['break_hours'] cells[5].text = row['work_hours'] cells[6].text = row['travel_hours'] cells[7].text = row['places'] cells[8].text = row['vehicle_km'] else: cells = hours_table.add_row().cells for index in range(9): cells[index].text = '-' _docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati') _docx_cleanup_service_report_template(doc) _docx_remove_empty_page_break_paragraphs(doc) doc.add_page_break() _docx_add_heading(doc, 'Servisni zapisi', level=2) has_records = False for task in related_tasks: task_records = records_by_task.get(task.id, []) if not task_records: continue has_records = True task_date_label = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-' _docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3) for record in task_records: doc.add_paragraph(f"Datum: {task_date_label}") doc.add_paragraph(f"Opis: {record.description or '-'}") doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}") doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR") doc.add_paragraph(f"Kilometraža: {record.mileage if record.mileage is not None else '-'}") photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image] if photos: doc.add_paragraph('Fotografije:') for photo in photos: image_stream = _compress_image_for_docx(photo.image) if image_stream is None: continue try: doc.add_picture(image_stream, width=Cm(16)) except Exception: continue photo_caption = str(photo.description or '').strip() if photo_caption: doc.add_paragraph(photo_caption) doc.add_paragraph('') if not has_records: doc.add_paragraph('Nema povezanih servisnih zapisa za ovaj putni nalog.') buffer = BytesIO() doc.save(buffer) return buffer.getvalue() def _build_work_order_invoices_docx_bytes(work_order): doc = _create_docx_document() _docx_add_heading(doc, f'Računi putnog naloga {_work_order_display_code(work_order)}', level=1) doc.add_paragraph(f'Datum generiranja: {timezone.localtime(timezone.now()).strftime("%d.%m.%Y %H:%M")}') doc.add_paragraph(f'Klijent: {getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"}') doc.add_paragraph(f'Dizalica: {work_order.vehicle.registration_number or "-"}') invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')) table = doc.add_table(rows=1, cols=5) table.rows[0].cells[0].text = 'Naziv računa' table.rows[0].cells[1].text = 'Lokacija' table.rows[0].cells[2].text = 'Datum' table.rows[0].cells[3].text = 'Opis' table.rows[0].cells[4].text = 'Prilog' if invoices: for invoice in invoices: cells = table.add_row().cells cells[0].text = str(invoice.naziv_racuna or '-') cells[1].text = str(invoice.lokacija or '-') cells[2].text = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-' cells[3].text = str(invoice.opis or '-') cells[4].text = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '-' else: cells = table.add_row().cells for index in range(5): cells[index].text = '-' additional_costs_table = getattr(work_order, 'additional_costs_table', None) if additional_costs_table and isinstance(additional_costs_table.data, dict): rows = additional_costs_table.data.get('rows', []) if isinstance(rows, list) and rows: doc.add_paragraph() _docx_add_heading(doc, 'Dodatni troškovi', level=2) costs_table = doc.add_table(rows=1, cols=3) costs_table.rows[0].cells[0].text = 'Naziv' costs_table.rows[0].cells[1].text = 'Broj računa' costs_table.rows[0].cells[2].text = 'Ukupan iznos' for row in rows: if not isinstance(row, dict): continue cells = costs_table.add_row().cells cells[0].text = str(row.get('naziv') or '-') cells[1].text = str(row.get('broj_racuna') or '-') cells[2].text = str(row.get('ukupan_iznos') or '-') buffer = BytesIO() doc.save(buffer) return buffer.getvalue() def _guess_content_type(filename): mime, _ = mimetypes.guess_type(str(filename or '')) return mime or 'application/octet-stream' def _file_attachment(file_field, fallback_name): if not file_field: return None file_field.open('rb') try: content = file_field.read() finally: file_field.close() if not content: return None filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name return (filename, content, _guess_content_type(filename)) def _build_image_attachments_for_work_order(work_order): attachments = [] photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at') for index, photo in enumerate(photos, start=1): attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin") if attachment: attachments.append(attachment) invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at') for index, invoice in enumerate(invoices, start=1): attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin") if attachment: attachments.append(attachment) return attachments def _parse_month_value(value): if value in (None, ''): today = timezone.localdate() return today.year, today.month, today.strftime('%Y-%m') normalized = str(value).strip() parts = normalized.split('-') if len(parts) != 2: raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) try: year = int(parts[0]) month = int(parts[1]) except ValueError: raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) if year < 2000 or year > 2100 or month < 1 or month > 12: raise DRFValidationError({'month': 'Mjesec mora biti u formatu YYYY-MM.'}) return year, month, f"{year:04d}-{month:02d}" def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key): from modules.task_management.models import Task tasks = ( Task.objects .filter( is_active=True, vehicle_id=work_order.vehicle_id, scheduled_date__year=year, scheduled_date__month=month, ) .select_related('assigned_to', 'work_order') .order_by('scheduled_date', 'created_at') ) csv_buffer = StringIO() writer = csv.writer(csv_buffer) writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis']) for task in tasks: assignee_name = _user_display_name(task.assigned_to) or '-' work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-' writer.writerow([ task.scheduled_date.isoformat() if task.scheduled_date else '', task.title or '', task.status or '', assignee_name, work_order_label, task.description or '', ]) csv_content = csv_buffer.getvalue().encode('utf-8') filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv" return (filename, csv_content, 'text/csv'), tasks.count() def _resolve_service_note_for_email(*, user, note_id, work_order): if note_id in (None, ''): return None note = ServiceContextNote.objects.select_related('created_by', 'recipient', 'work_order').filter( pk=note_id, is_active=True, ).first() if note is None: raise DRFValidationError({'service_note_id': 'Odabrana bilješka ne postoji ili nije aktivna.'}) if note.work_order_id and str(note.work_order_id) != str(work_order.pk): raise DRFValidationError({'service_note_id': 'Bilješka nije povezana s ovim putnim nalogom.'}) if not (user.is_staff or str(note.created_by_id) == str(user.id) or str(note.recipient_id) == str(user.id)): raise DRFValidationError({'service_note_id': 'Nemate dozvolu za korištenje odabrane bilješke.'}) return note def _send_email_with_attachments(*, recipients, subject, body, attachments): if not recipients: raise DRFValidationError({'recipients': 'Nije pronađena nijedna email adresa za slanje.'}) message = EmailMessage( subject=subject, body=body, from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None), to=recipients, ) for filename, content, content_type in attachments: message.attach(filename, content, content_type) message.send(fail_silently=False) def _dispatch_work_order_email_background(task_kwargs): try: send_work_order_email_bundle_task.delay(**task_kwargs) return 'celery' except KombuOperationalError: pass def _run_fallback(): try: send_work_order_email_bundle_task.run(**task_kwargs) except Exception: logger.exception("Greška u fallback background slanju emaila za putni nalog.") thread = threading.Thread(target=_run_fallback, name='work-order-email-fallback', daemon=True) thread.start() return 'thread' _MONTH_NAMES_HR = [ 'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj', 'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac', ] def _format_report_hours(value, default='-'): if value in (None, ''): return default try: normalized = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return str(value) text = format(normalized.normalize(), 'f') if '.' in text: text = text.rstrip('0').rstrip('.') return text or '0' def _build_monthly_servicer_report_rows(user, year, month): from calendar import monthrange from datetime import date as _date from modules.task_management.models import Task as _TaskModel tasks_qs = ( _TaskModel.objects .filter( assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, ) .select_related( 'vehicle', 'vehicle__client', 'work_order', 'work_order__vehicle', 'work_order__vehicle__client', 'work_hours_table', ) .order_by('scheduled_date', 'created_at') ) manual_entries = MonthlyServicerDayEntry.objects.filter( user=user, is_active=True, entry_date__year=year, entry_date__month=month, ).order_by('entry_date') entries_by_date = OrderedDict() for task in tasks_qs: for entry in _task_work_hours_entries(task): entries_by_date.setdefault(entry['date'], []).append(entry) manual_by_date = { entry.entry_date: entry for entry in manual_entries } rows = [] days_in_month = monthrange(year, month)[1] for day in range(1, days_in_month + 1): current_date = _date(year, month, day) day_entries = entries_by_date.get(current_date, []) if day_entries: titles = OrderedDict() serials = OrderedDict() clients = OrderedDict() locations = OrderedDict() work_orders = OrderedDict() start_values = [] end_values = [] total_hours = Decimal('0.00') for entry in day_entries: if entry.get('title'): titles[entry['title']] = entry['title'] if entry.get('serial'): serials[entry['serial']] = entry['serial'] if entry.get('client'): clients[entry['client']] = entry['client'] if entry.get('location'): locations[entry['location']] = entry['location'] if entry.get('work_order_label'): work_orders[entry['work_order_label']] = entry['work_order_label'] if entry.get('start_dt'): start_values.append(entry['start_dt']) if entry.get('end_dt'): end_values.append(entry['end_dt']) total_hours += entry.get('total_hours', Decimal('0.00')) start_label = '-' end_label = '-' if start_values: start_label = min(start_values).strftime('%H:%M') if end_values: end_label = max(end_values).strftime('%H:%M') rows.append({ 'date': current_date, 'datum': current_date.strftime('%d.%m.%Y'), 'opis_posla': ' | '.join(titles.values()) or '-', 'br_dizalice': ', '.join(serials.values()) or '-', 'komitent': ', '.join(clients.values()) or '-', 'mjesto_rada': ', '.join(locations.values()) or '-', 'pocetak_rada': start_label, 'kraj_rada': end_label, 'redovan_rad': '8', 'prekovremeni': _format_report_hours(total_hours, default='0'), 'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-', 'source': 'task', }) continue manual_entry = manual_by_date.get(current_date) if manual_entry: rows.append({ 'date': current_date, 'datum': current_date.strftime('%d.%m.%Y'), 'opis_posla': str(manual_entry.description or '').strip() or '-', 'br_dizalice': '-', 'komitent': '-', 'mjesto_rada': str(manual_entry.location or '').strip() or '-', 'pocetak_rada': manual_entry.start_time.strftime('%H:%M') if manual_entry.start_time else '-', 'kraj_rada': manual_entry.end_time.strftime('%H:%M') if manual_entry.end_time else '-', 'redovan_rad': _format_report_hours(manual_entry.regular_hours, default='0'), 'prekovremeni': _format_report_hours(manual_entry.overtime_hours, default='0'), 'radni_nalog': '-', 'source': manual_entry.entry_type, }) continue rows.append({ 'date': current_date, 'datum': current_date.strftime('%d.%m.%Y'), 'opis_posla': '-', 'br_dizalice': '-', 'komitent': '-', 'mjesto_rada': '-', 'pocetak_rada': '-', 'kraj_rada': '-', 'redovan_rad': '0', 'prekovremeni': '0', 'radni_nalog': '-', 'source': 'empty', }) return rows class MonthlyServicerDayEntryViewSet(viewsets.ModelViewSet): serializer_class = MonthlyServicerDayEntrySerializer permission_classes = [permissions.IsAuthenticated] http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] def get_queryset(self): queryset = MonthlyServicerDayEntry.objects.filter( user=self.request.user, is_active=True, ).order_by('entry_date') year = self.request.query_params.get('year') month = self.request.query_params.get('month') if year and str(year).isdigit(): queryset = queryset.filter(entry_date__year=int(year)) if month and str(month).isdigit(): queryset = queryset.filter(entry_date__month=int(month)) return queryset def create(self, request, *args, **kwargs): raw_entry_date = request.data.get('entry_date') existing = None if raw_entry_date: existing = self.get_queryset().filter(entry_date=raw_entry_date).first() if existing is not None: serializer = self.get_serializer(existing, data=request.data, partial=True) serializer.is_valid(raise_exception=True) entry = serializer.save() return Response(self.get_serializer(entry).data, status=status.HTTP_200_OK) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) entry = serializer.save(user=request.user) return Response(self.get_serializer(entry).data, status=status.HTTP_201_CREATED) def perform_destroy(self, instance): instance.is_active = False instance.save(update_fields=['is_active', 'updated_at']) @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def monthly_servicer_report_docx(request): """Download monthly servicer report as DOCX (landscape table).""" from datetime import date as _dt_date try: year = int(request.query_params.get('year', _dt_date.today().year)) month = int(request.query_params.get('month', _dt_date.today().month)) if not (1 <= month <= 12): raise ValueError() except (ValueError, TypeError): return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) try: normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month) except ImportError: return Response({'detail': 'Task model nije dostupan.'}, status=500) from docx import Document as _DocxDoc from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.section import WD_ORIENT doc = _DocxDoc() for section in doc.sections: section.orientation = WD_ORIENT.LANDSCAPE section.page_width = Cm(29.7) section.page_height = Cm(21.0) section.left_margin = Cm(1.5) section.right_margin = Cm(1.5) section.top_margin = Cm(1.5) section.bottom_margin = Cm(1.5) month_label = _MONTH_NAMES_HR[month - 1] servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user)) p_title = doc.add_paragraph() p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ SERVISERA') r_title.bold = True r_title.font.size = Pt(14) p_sub = doc.add_paragraph() p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}") r_sub.font.size = Pt(11) headers = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POČETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG'] col_widths = [Cm(2.2), Cm(5.5), Cm(2.5), Cm(3.5), Cm(3.5), Cm(2.2), Cm(2.2), Cm(2.4), Cm(2.4), Cm(2.4)] table = doc.add_table(rows=1 + len(normalized_rows), cols=len(headers)) table.style = 'Table Grid' hdr_cells = table.rows[0].cells for i, (hdr, w) in enumerate(zip(headers, col_widths)): hdr_cells[i].width = w hdr_cells[i].text = hdr if hdr_cells[i].paragraphs[0].runs: run_h = hdr_cells[i].paragraphs[0].runs[0] run_h.bold = True run_h.font.size = Pt(8) for ri, row_data in enumerate(normalized_rows): data_cells = table.rows[ri + 1].cells values = [ row_data['datum'], row_data['opis_posla'], row_data['br_dizalice'], row_data['komitent'], row_data['mjesto_rada'], row_data['pocetak_rada'], row_data['kraj_rada'], row_data['redovan_rad'], row_data['prekovremeni'], row_data['radni_nalog'], ] for ci, (val, w) in enumerate(zip(values, col_widths)): data_cells[ci].width = w data_cells[ci].text = str(val) if data_cells[ci].paragraphs[0].runs: data_cells[ci].paragraphs[0].runs[0].font.size = Pt(8) buf = BytesIO() doc.save(buf) buf.seek(0) month_key = f"{year}-{month:02d}" safe_name = re.sub(r'[^\w\-]', '_', servicer_name) fname = f"{safe_name}.izvjestaj-servisera.{month_key}.docx" resp = HttpResponse( buf.read(), content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) resp['Content-Disposition'] = f'attachment; filename="{fname}"' return resp @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def monthly_costs_report_docx(request): """Download monthly servicer costs (invoices) report as DOCX.""" from datetime import date as _dt_date try: year = int(request.query_params.get('year', _dt_date.today().year)) month = int(request.query_params.get('month', _dt_date.today().month)) if not (1 <= month <= 12): raise ValueError() except (ValueError, TypeError): return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) invoices_qs = ( WorkOrderInvoice.objects .filter( work_order__work_order_tasks__assigned_to=request.user, work_order__work_order_tasks__is_active=True, datum__year=year, datum__month=month, ) .select_related('work_order') .distinct() .order_by('datum', 'naziv_racuna') ) rows = [] for inv in invoices_qs: wo = inv.work_order rows.append([ inv.datum.strftime('%d.%m.%Y') if inv.datum else '-', inv.naziv_racuna or '-', inv.lokacija or '-', inv.opis or '-', str(getattr(wo, 'display_code', '') or '').strip() or '-', ]) from docx import Document as _DocxDoc from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.section import WD_ORIENT doc = _DocxDoc() for section in doc.sections: section.orientation = WD_ORIENT.LANDSCAPE section.page_width = Cm(29.7) section.page_height = Cm(21.0) section.left_margin = Cm(1.5) section.right_margin = Cm(1.5) section.top_margin = Cm(1.5) section.bottom_margin = Cm(1.5) month_label = _MONTH_NAMES_HR[month - 1] servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user)) p_title = doc.add_paragraph() p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ TROŠKOVA SERVISERA') r_title.bold = True r_title.font.size = Pt(14) p_sub = doc.add_paragraph() p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}") r_sub.font.size = Pt(11) headers = ['DATUM', 'NAZIV RAČUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'] col_widths = [Cm(2.5), Cm(6.0), Cm(4.0), Cm(8.0), Cm(3.0)] table = doc.add_table(rows=1 + len(rows), cols=len(headers)) table.style = 'Table Grid' hdr_cells = table.rows[0].cells for i, (hdr, w) in enumerate(zip(headers, col_widths)): hdr_cells[i].width = w hdr_cells[i].text = hdr if hdr_cells[i].paragraphs[0].runs: run_h = hdr_cells[i].paragraphs[0].runs[0] run_h.bold = True run_h.font.size = Pt(9) for ri, row_data in enumerate(rows): data_cells = table.rows[ri + 1].cells for ci, (val, w) in enumerate(zip(row_data, col_widths)): data_cells[ci].width = w data_cells[ci].text = str(val) if data_cells[ci].paragraphs[0].runs: data_cells[ci].paragraphs[0].runs[0].font.size = Pt(9) buf = BytesIO() doc.save(buf) buf.seek(0) month_key = f"{year}-{month:02d}" safe_name = re.sub(r'[^\w\-]', '_', servicer_name) fname = f"{safe_name}.troskovi-servisera.{month_key}.docx" resp = HttpResponse( buf.read(), content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) resp['Content-Disposition'] = f'attachment; filename="{fname}"' return resp def _monthly_archive_prefix_for_user(user): first_name = str(getattr(user, 'first_name', '') or '').strip() last_name = str(getattr(user, 'last_name', '') or '').strip() if first_name and last_name: return f"{first_name[0].upper()}{last_name[0].upper()}" return 'MT' def _generated_archive_filename_for_user(user, *, year, month, archive_type): prefix = _monthly_archive_prefix_for_user(user) suffix = 'SN' if archive_type == 'service_tasks' else 'PN' return f"{prefix}-{month:02d}-{year}-{suffix}.zip" def _unique_zip_entry_name(entry_name, used_names): candidate = entry_name entry_path = Path(entry_name) stem = entry_path.stem suffix = entry_path.suffix parent = str(entry_path.parent) counter = 2 while candidate in used_names: filename = f"{stem}-{counter}{suffix}" candidate = f"{parent}/{filename}" if parent not in ('', '.') else filename counter += 1 used_names.add(candidate) return candidate def _parse_year_month_params(request): from datetime import date as _dt_date raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None if raw_year in (None, ''): raw_year = request.query_params.get('year', _dt_date.today().year) if raw_month in (None, ''): raw_month = request.query_params.get('month', _dt_date.today().month) try: year = int(raw_year) month = int(raw_month) if not (1 <= month <= 12): raise ValueError() except (ValueError, TypeError): raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'}) return year, month def _build_monthly_service_tasks_archive_content(*, user, year, month): from modules.task_management.models import Task tasks = list( Task.objects .filter( assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, work_order__isnull=False, work_order__is_active=True, ) .select_related('work_order', 'vehicle', 'work_hours_table') .order_by('scheduled_date', 'created_at') ) if not tasks: raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'}) used_names = set() archive_buffer = BytesIO() with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: for task in tasks: work_order = task.work_order if work_order is None: continue docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task]) base_name = _service_records_docx_filename(work_order, task) entry_name = _unique_zip_entry_name(base_name, used_names) archive.writestr(entry_name, docx_bytes) archive_content = archive_buffer.getvalue() if not archive_content: raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) return archive_content def _build_monthly_work_orders_archive_content(*, user, year, month): from modules.task_management.models import Task monthly_tasks = ( Task.objects .filter( assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, work_order__isnull=False, work_order__is_active=True, ) .select_related('work_order') .order_by('scheduled_date', 'created_at') ) ordered_work_order_ids = [] seen_work_order_ids = set() for task in monthly_tasks: if not task.work_order_id or task.work_order_id in seen_work_order_ids: continue seen_work_order_ids.add(task.work_order_id) ordered_work_order_ids.append(task.work_order_id) if not ordered_work_order_ids: raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) work_orders_map = { work_order.id: work_order for work_order in ( WorkOrder.objects .filter(id__in=ordered_work_order_ids, is_active=True) .select_related('vehicle', 'vehicle__client', 'creator') ) } work_orders = [work_orders_map[work_order_id] for work_order_id in ordered_work_order_ids if work_order_id in work_orders_map] if not work_orders: raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) used_names = set() archive_buffer = BytesIO() with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: for work_order in work_orders: pdf_bytes = _build_work_order_pdf(work_order) work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names) archive.writestr(work_order_pdf_name, pdf_bytes) display_code = _work_order_display_code(work_order) invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at') for index, invoice in enumerate(invoices, start=1): attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin") if not attachment: continue invoice_filename, content, _content_type = attachment archive_path = f"Racuni/{display_code}/{invoice_filename}" archive.writestr(_unique_zip_entry_name(archive_path, used_names), content) archive_content = archive_buffer.getvalue() if not archive_content: raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) return archive_content def _cleanup_expired_generated_archive_records(): now = timezone.now() expired = GeneratedFleetArchive.objects.filter( is_active=True, expires_at__isnull=False, expires_at__lte=now, ) for item in expired: if item.file: item.file.delete(save=False) item.is_active = False item.status = 'failed' item.error_message = 'ZIP arhiva je istekla.' item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None): if user is None: return month_label = f"{month:02d}.{year}." if archive_type == 'service_tasks': archive_label = 'pojedinačnih servisnih taskova' else: archive_label = 'putnih naloga i računa' if stage == 'requested': title = "ZIP arhiva u pripremi" message = f"Zaprimljen je zahtjev za generiranje ZIP arhive {archive_label} za {month_label}" level = 'info' elif stage == 'failed': title = "Greška kod ZIP arhive" message = f"Generiranje ZIP arhive {archive_label} nije uspjelo za {month_label}" level = 'warning' else: title = "ZIP arhiva spremna" message = f"ZIP arhiva {archive_label} je spremna za preuzimanje ({month_label})" level = 'success' metadata = { 'entity_type': 'fleet_archive', 'archive_type': archive_type, 'stage': stage, 'year': year, 'month': month, 'section': 'service-records', } if generated_archive and generated_archive.pk: metadata['generated_archive_id'] = str(generated_archive.pk) metadata['download_url'] = f"fleet/reports/generated-archives/{generated_archive.pk}/download/" metadata['filename'] = generated_archive.filename or _generated_archive_filename_for_user( user, year=year, month=month, archive_type=archive_type, ) if generated_archive.expires_at: metadata['expires_at'] = generated_archive.expires_at.isoformat() NotificationService.create_notification( recipient=user, title=title, message=message, level=level, send_email=False, metadata=metadata, ) def _get_cached_generated_archive(*, user, archive_type, year, month): return ( GeneratedFleetArchive.objects .filter( is_active=True, requested_by=user, archive_type=archive_type, year=year, month=month, status='ready', expires_at__gt=timezone.now(), ) .exclude(file='') .exclude(file__isnull=True) .order_by('-created_at') .first() ) def _latest_monthly_archive_source_update(*, user, archive_type, year, month): from modules.task_management.models import Task base_tasks = Task.objects.filter( assigned_to=user, is_active=True, scheduled_date__year=year, scheduled_date__month=month, work_order__isnull=False, work_order__is_active=True, ) latest_candidates = [ base_tasks.aggregate(value=Max('updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'), ] if archive_type == 'work_orders': latest_candidates.append( base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value') ) latest_values = [value for value in latest_candidates if value is not None] if not latest_values: return None return max(latest_values) def _schedule_monthly_archive_local_fallback(generated_archive_id): def _runner(): try: build_monthly_archive_cached_task.apply(args=[generated_archive_id]).get() cleanup_expired_generated_archives_task.apply().get() except Exception as exc: logger.exception("Lokalni fallback za ZIP arhivu nije uspio: %s", exc) threading.Thread(target=_runner, daemon=True).start() def _request_monthly_archive_generation(*, request, archive_type): year, month = _parse_year_month_params(request) _cleanup_expired_generated_archive_records() cached = _get_cached_generated_archive( user=request.user, archive_type=archive_type, year=year, month=month, ) if cached: if archive_type == 'service_tasks': cached.is_active = False cached.status = 'failed' cached.error_message = 'ZIP arhiva servisnih taskova se regenerira za najnoviji kontekst.' cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) else: latest_source_update = _latest_monthly_archive_source_update( user=request.user, archive_type=archive_type, year=year, month=month, ) if latest_source_update and cached.updated_at and cached.updated_at < latest_source_update: cached.is_active = False cached.status = 'failed' cached.error_message = 'ZIP arhiva zastarjela zbog novijih izmjena izvora.' cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) else: _notify_monthly_archive_request( user=request.user, archive_type=archive_type, stage='completed', year=year, month=month, generated_archive=cached, ) return { 'status': 'ready', 'generated_archive_id': str(cached.pk), 'download_url': f"fleet/reports/generated-archives/{cached.pk}/download/", 'filename': cached.filename, 'expires_at': cached.expires_at.isoformat() if cached.expires_at else None, } existing_pending = ( GeneratedFleetArchive.objects .filter( is_active=True, requested_by=request.user, archive_type=archive_type, year=year, month=month, status='pending', ) .order_by('-created_at') .first() ) if existing_pending: stale_pending_threshold = timezone.now() - timedelta(minutes=3) if existing_pending.created_at and existing_pending.created_at < stale_pending_threshold: existing_pending.is_active = False existing_pending.status = 'failed' existing_pending.error_message = 'ZIP zahtjev je ostao predugo u pending statusu; pokrece se novi zahtjev.' existing_pending.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) else: return { 'status': 'processing', 'generated_archive_id': str(existing_pending.pk), } generated_archive = GeneratedFleetArchive.objects.create( requested_by=request.user, archive_type=archive_type, year=year, month=month, status='pending', filename=_generated_archive_filename_for_user( request.user, year=year, month=month, archive_type=archive_type, ), expires_at=timezone.now() + timedelta(days=GENERATED_ARCHIVE_TTL_DAYS), ) _notify_monthly_archive_request( user=request.user, archive_type=archive_type, stage='requested', year=year, month=month, generated_archive=generated_archive, ) try: build_monthly_archive_cached_task.delay(str(generated_archive.pk)) cleanup_expired_generated_archives_task.delay() except KombuOperationalError: _schedule_monthly_archive_local_fallback(str(generated_archive.pk)) return { 'status': 'processing', 'generated_archive_id': str(generated_archive.pk), } @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def monthly_service_tasks_archive(request): year, month = _parse_year_month_params(request) archive_content = _build_monthly_service_tasks_archive_content( user=request.user, year=year, month=month, ) archive_filename = _generated_archive_filename_for_user( request.user, year=year, month=month, archive_type='service_tasks', ) response = HttpResponse(archive_content, content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' return response @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def monthly_service_tasks_archive_request(request): payload = _request_monthly_archive_generation(request=request, archive_type='service_tasks') return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def monthly_work_orders_archive(request): year, month = _parse_year_month_params(request) archive_content = _build_monthly_work_orders_archive_content( user=request.user, year=year, month=month, ) archive_filename = _generated_archive_filename_for_user( request.user, year=year, month=month, archive_type='work_orders', ) response = HttpResponse(archive_content, content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' return response @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def monthly_work_orders_archive_request(request): payload = _request_monthly_archive_generation(request=request, archive_type='work_orders') return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) @api_view(['GET']) @permission_classes([permissions.IsAuthenticated]) def generated_archive_download(request, archive_id): _cleanup_expired_generated_archive_records() generated_archive = ( GeneratedFleetArchive.objects .filter( is_active=True, requested_by=request.user, pk=archive_id, status='ready', expires_at__gt=timezone.now(), ) .exclude(file='') .exclude(file__isnull=True) .first() ) if generated_archive is None: raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'}) generated_archive.file.open('rb') try: archive_bytes = generated_archive.file.read() finally: generated_archive.file.close() if not archive_bytes: raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'}) filename = generated_archive.filename or _generated_archive_filename_for_user( request.user, year=generated_archive.year, month=generated_archive.month, archive_type=generated_archive.archive_type, ) response = HttpResponse(archive_bytes, content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Cache-Control'] = 'private, max-age=3600' response['Content-Length'] = str(len(archive_bytes)) return response @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def pusher_auth(request): """ Authenticate user for private Pusher channel. Endpoint koji frontend poziva za auth na privatnom kanalu. """ socket_id = request.data.get('socket_id') channel_name = request.data.get('channel_name') if not socket_id or not channel_name: return Response({'error': 'Missing socket_id or channel_name'}, status=400) auth_data = PusherService.authenticate_channel( socket_id=socket_id, channel_name=channel_name, user_id=request.user.id ) if not auth_data: return Response({'error': 'Authentication failed'}, status=403) return Response(auth_data) class VehicleViewSet(viewsets.ModelViewSet): serializer_class = VehicleSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle') class CraneViewSet(viewsets.ModelViewSet): serializer_class = CraneSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane') class WorkOrderViewSet(viewsets.ModelViewSet): serializer_class = WorkOrderSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return _work_orders_queryset_for_user(self.request.user) def perform_update(self, serializer): work_order = serializer.save() _invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'service_records', 'invoices']) @action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser]) def images(self, request, pk=None): work_order = self.get_object() if request.method.lower() == 'post': serializer = WorkOrderPhotoSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) serializer.save(work_order=work_order, uploaded_by=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) quality = _parse_positive_int( request.query_params.get('q'), field_name='q', default=75, min_value=30, max_value=95, ) width = _parse_positive_int( request.query_params.get('w'), field_name='w', default=1280, min_value=160, max_value=3840, ) image_format, _ = _parse_format(request.query_params.get('fmt')) photos_qs = WorkOrderPhoto.objects.filter( is_active=True, work_order_id=work_order.pk, ).select_related('uploaded_by').order_by('-created_at') images = [] for photo in photos_qs: file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk}) original_url = request.build_absolute_uri(file_path) optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}") images.append({ "id": photo.pk, "work_order_id": photo.work_order_id, "description": photo.description, "original_url": original_url, "optimized_url": optimized_url, "created_at": photo.created_at, }) return Response({ "work_order_id": work_order.pk, "vehicle_id": work_order.vehicle_id, "image_quality": quality, "image_width": width, "image_format": image_format.lower(), "images": images, }, status=status.HTTP_200_OK) @action(detail=True, methods=['get'], url_path=r'images/(?P[^/.]+)/file') def image_file(self, request, pk=None, photo_id=None): work_order = self.get_object() photo = WorkOrderPhoto.objects.filter( is_active=True, work_order_id=work_order.pk, pk=photo_id, ).first() if photo is None or not photo.image: raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."}) width = _parse_positive_int( request.query_params.get('w'), field_name='w', default=1280, min_value=160, max_value=3840, ) quality = _parse_positive_int( request.query_params.get('q'), field_name='q', default=75, min_value=30, max_value=95, ) image_format, extension = _parse_format(request.query_params.get('fmt')) try: photo.image.open('rb') with Image.open(photo.image) as source: image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() if width and image.width > width: ratio = width / float(image.width) target_height = max(1, int(image.height * ratio)) image = image.resize((width, target_height), Image.Resampling.LANCZOS) buffer = BytesIO() save_kwargs = {"format": image_format, "optimize": True} if image_format in ('WEBP', 'JPEG'): save_kwargs["quality"] = quality image.save(buffer, **save_kwargs) except (UnidentifiedImageError, OSError): raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) finally: photo.image.close() response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") response['Cache-Control'] = 'private, max-age=86400' response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"' return response @action(detail=True, methods=['get'], url_path='pdf') def pdf(self, request, pk=None): work_order = self.get_object() _cleanup_expired_generated_pdfs() cached = _get_cached_pdf(work_order, 'work_order') if cached: return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order')) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested') pdf_bytes = _build_work_order_pdf(work_order) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed') response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"' return response @action(detail=True, methods=['get'], url_path='docx') def docx(self, request, pk=None): work_order = self.get_object() docx_bytes = _build_work_order_docx_bytes(work_order) response = HttpResponse( docx_bytes, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "work_order")}"' return response @action(detail=True, methods=['post'], url_path='pdf-request') def pdf_request(self, request, pk=None): work_order = self.get_object() payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order') return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) @action(detail=True, methods=['get'], url_path='service-records-pdf') def service_records_pdf(self, request, pk=None): work_order = self.get_object() task_id = request.query_params.get('task_id') related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id) if not selected_task: _cleanup_expired_generated_pdfs() cached = _get_cached_pdf(work_order, 'service_records') if cached: return _cached_pdf_file_response( cached, default_filename=_service_records_pdf_filename(work_order), ) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested') pdf_bytes = _build_work_order_service_records_pdf(work_order, related_tasks=related_tasks) if not selected_task: _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed') response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{_service_records_pdf_filename(work_order, selected_task)}"' return response @action(detail=True, methods=['get'], url_path='service-records-docx') def service_records_docx(self, request, pk=None): work_order = self.get_object() task_id = request.query_params.get('task_id') related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id) docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=related_tasks) response = HttpResponse( docx_bytes, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) response['Content-Disposition'] = f'attachment; filename="{_service_records_docx_filename(work_order, selected_task)}"' return response @action(detail=True, methods=['post'], url_path='service-records-pdf-request') def service_records_pdf_request(self, request, pk=None): work_order = self.get_object() payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records') return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) @action(detail=True, methods=['get'], url_path='invoices-pdf') def invoices_pdf(self, request, pk=None): work_order = self.get_object() _cleanup_expired_generated_pdfs() cached = _get_cached_pdf(work_order, 'invoices') if cached: return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices')) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested') pdf_bytes = _build_work_order_invoices_pdf_bytes(work_order) filename = _pdf_filename(work_order, 'invoices') _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed') response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{filename}"' return response @action(detail=True, methods=['get'], url_path='invoices-docx') def invoices_docx(self, request, pk=None): work_order = self.get_object() docx_bytes = _build_work_order_invoices_docx_bytes(work_order) response = HttpResponse( docx_bytes, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "invoices")}"' return response @action(detail=True, methods=['post'], url_path='invoices-pdf-request') def invoices_pdf_request(self, request, pk=None): work_order = self.get_object() payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices') return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) @action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P[^/.]+)/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."}) file_name = getattr(generated_pdf.file, 'name', '') storage = getattr(generated_pdf.file, 'storage', None) if not file_name or storage is None: _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) try: if not storage.exists(file_name): _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) except (FileNotFoundError, OSError, ValueError): _mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.') raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."}) 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 = [] # Filter records by the task's own vehicle (not work_order.vehicle) to support # cross-crane work orders where each task may belong to a different crane. task_vehicle_id = task.vehicle_id for record in task.service_records.filter(is_active=True, vehicle_id=task_vehicle_id).all(): photos_payload = [] for photo in record.photos.filter(is_active=True).all(): if not photo.image: continue optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk}) photos_payload.append({ 'id': photo.pk, 'image_url': request.build_absolute_uri(photo.image.url), 'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"), 'description': photo.description, }) records_payload.append({ 'id': record.pk, 'service_title': record.service_title, 'description': record.description, 'service_date': record.service_date, 'cost': str(record.cost), 'mileage': record.mileage, 'performed_by_name': _user_display_name(record.performed_by), 'vehicle_registration': getattr(record.vehicle, 'registration_number', None), 'photos': photos_payload, }) payload.append({ 'id': task.pk, 'title': task.title, 'status': task.status, 'description': task.description, 'scheduled_date': task.scheduled_date, 'service_report_note': task.service_report_note or '', 'assigned_to_name': _user_display_name(task.assigned_to), 'vehicle_registration': getattr(task.vehicle, 'registration_number', None), 'task_work_order': ( { 'id': task.work_order_id, 'display_code': getattr(task.work_order, 'display_code', None), 'origin_location': getattr(task.work_order, 'origin_location', None), 'location': getattr(task.work_order, 'location', None), 'travel_start_at': getattr(task.work_order, 'travel_start_at', None), 'travel_end_at': getattr(task.work_order, 'travel_end_at', None), 'start_mileage': getattr(task.work_order, 'start_mileage', None), 'end_mileage': getattr(task.work_order, 'end_mileage', None), 'distance': getattr(task.work_order, 'distance', None), 'servicer_vehicle_registration': getattr(task.work_order, 'servicer_vehicle_registration', None), 'servicer_vehicle_make_model': getattr(task.work_order, 'servicer_vehicle_make_model', None), } if task.work_order_id else 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'} ) travel_expenses_context = _work_order_travel_expenses_context(work_order) travel_expenses_table = getattr(work_order, 'travel_expenses_table', None) travel_expenses_payload = ( WorkOrderTravelExpensesTableSerializer(travel_expenses_table).data if travel_expenses_table else { 'work_order': str(work_order.pk), 'broj_sati': _format_decimal_fixed(travel_expenses_context['broj_sati']), 'kolicina_dnevnica': _format_decimal_fixed(travel_expenses_context['kolicina_dnevnica']), 'iznos_dnevnica': _format_decimal_fixed(travel_expenses_context['iznos_dnevnica']), 'daily_rate_country': WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY, 'total_for_payout': _format_decimal_fixed(travel_expenses_context['total_for_payout']), } ) return Response({ 'work_order_id': work_order.pk, 'tasks': payload, 'additional_costs_table': additional_costs_payload, 'travel_expenses_table': travel_expenses_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=['get', 'put', 'patch'], url_path='travel-expenses-table') def travel_expenses_table(self, request, pk=None): work_order = self.get_object() table = getattr(work_order, 'travel_expenses_table', None) fallback = _work_order_travel_expenses_context(work_order) if request.method.lower() == 'get': if table: return Response(WorkOrderTravelExpensesTableSerializer(table).data, status=status.HTTP_200_OK) return Response({ 'work_order': str(work_order.pk), 'broj_sati': _format_decimal_fixed(fallback['broj_sati']), 'kolicina_dnevnica': _format_decimal_fixed(fallback['kolicina_dnevnica']), 'iznos_dnevnica': _format_decimal_fixed(fallback['iznos_dnevnica']), 'daily_rate_country': fallback['daily_rate_country'], 'total_for_payout': _format_decimal_fixed(fallback['total_for_payout']), }, status=status.HTTP_200_OK) serializer = WorkOrderTravelExpensesTableSerializer( table, data={ 'work_order': str(work_order.pk), 'broj_sati': request.data.get('broj_sati', fallback['broj_sati']), 'kolicina_dnevnica': request.data.get('kolicina_dnevnica', fallback['kolicina_dnevnica']), 'iznos_dnevnica': request.data.get('iznos_dnevnica', fallback['iznos_dnevnica']), 'daily_rate_country': request.data.get('daily_rate_country', fallback['daily_rate_country']), }, 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']) return Response(WorkOrderTravelExpensesTableSerializer(instance).data, status=status.HTTP_200_OK) @action(detail=True, methods=['post'], url_path='send-email') def send_email(self, request, pk=None): work_order = self.get_object() recipients = _parse_recipients( request.data, fallback_candidates=[ getattr(getattr(work_order.vehicle, 'client', None), 'email', None), getattr(work_order.creator, 'email', None), ], ) if not recipients: raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."}) include_work_order_pdf = _parse_boolean_flag( request.data.get('include_work_order_pdf'), field_name='include_work_order_pdf', default=True, ) include_service_records_pdf = _parse_boolean_flag( request.data.get('include_service_records_pdf'), field_name='include_service_records_pdf', default=False, ) include_invoices_pdf = _parse_boolean_flag( request.data.get('include_invoices_pdf'), field_name='include_invoices_pdf', default=False, ) include_images = _parse_boolean_flag( request.data.get('include_images'), field_name='include_images', default=False, ) include_monthly_tasks = _parse_boolean_flag( request.data.get('include_monthly_tasks'), field_name='include_monthly_tasks', default=False, ) selected_note = _resolve_service_note_for_email( user=request.user, note_id=request.data.get('service_note_id') or request.data.get('note_id'), work_order=work_order, ) if not any([ include_work_order_pdf, include_service_records_pdf, include_invoices_pdf, include_images, include_monthly_tasks, ]): raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."}) month_year = None month_number = None month_key = '' if include_monthly_tasks: month_year, month_number, month_key = _parse_month_value(request.data.get('month')) subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}" message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga." selected_attachment_flags = [] if include_work_order_pdf: selected_attachment_flags.append({'type': 'work_order_pdf'}) if include_service_records_pdf: selected_attachment_flags.append({'type': 'service_records_pdf'}) if include_invoices_pdf: selected_attachment_flags.append({'type': 'invoices_pdf'}) if include_images: selected_attachment_flags.append({'type': 'images'}) if include_monthly_tasks: selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key}) dispatch_log = EmailDispatchLog.objects.create( dispatch_type='work_order', requested_by=request.user, work_order=work_order, recipients=recipients, subject=subject, message=message, attachments=selected_attachment_flags, status='queued', metadata={ 'service_note_id': str(selected_note.pk) if selected_note else None, }, ) NotificationService.create_notification( recipient=request.user, title='Slanje emaila u obradi', message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.', level='info', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'requested', 'work_order_id': str(work_order.pk), 'recipients_count': len(recipients), }, ) dispatch_backend = _dispatch_work_order_email_background( { 'work_order_id': str(work_order.pk), 'requested_by_id': str(request.user.id), 'recipients': recipients, 'subject': subject, 'message': message, 'include_work_order_pdf': include_work_order_pdf, 'include_service_records_pdf': include_service_records_pdf, 'include_invoices_pdf': include_invoices_pdf, 'include_images': include_images, 'include_monthly_tasks': include_monthly_tasks, 'month_year': month_year, 'month_number': month_number, 'month_key': month_key, 'service_note_id': str(selected_note.pk) if selected_note else None, 'dispatch_log_id': str(dispatch_log.pk), } ) return Response( { "queued": True, "recipients": recipients, "work_order_id": str(work_order.pk), "dispatch_backend": dispatch_backend, }, status=status.HTTP_202_ACCEPTED, ) class VehicleServiceRecordViewSet(viewsets.ModelViewSet): serializer_class = VehicleServiceRecordSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): user = self.request.user return _service_records_queryset_for_user(user) @action(detail=True, methods=['get'], url_path='pdf') def pdf(self, request, pk=None): service_record = self.get_object() pdf_bytes = _build_service_record_pdf(service_record) response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"' return response @action(detail=True, methods=['post'], url_path='send-email') def send_email(self, request, pk=None): service_record = self.get_object() recipient = _first_non_empty( request.data.get('recipient'), getattr(getattr(service_record.vehicle, 'client', None), 'email', None), getattr(service_record.performed_by, 'email', None), ) if not recipient: raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) pdf_bytes = _build_service_record_pdf(service_record) subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}" body = _first_non_empty(request.data.get('message')) or ( f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}." ) body_with_signature = append_user_signature(body, request.user) _send_document_email( recipient=recipient, subject=subject, body=body_with_signature, filename=f"{service_record.pk}.service-record.pdf", pdf_bytes=pdf_bytes, ) EmailDispatchLog.objects.create( dispatch_type='service_record', requested_by=request.user, service_record=service_record, recipients=[recipient], subject=subject, message=body_with_signature, attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}], status='sent', sent_at=timezone.now(), ) return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) class WorkOrderInvoiceViewSet(viewsets.ModelViewSet): serializer_class = WorkOrderInvoiceSerializer permission_classes = [permissions.IsAuthenticated] parser_classes = (MultiPartParser, FormParser) def get_queryset(self): user = self.request.user work_order_id = self.request.query_params.get('work_order_id') year = self.request.query_params.get('year') month = self.request.query_params.get('month') qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True) if year and month: try: year_value = int(year) month_value = int(month) except (TypeError, ValueError): year_value = None month_value = None if year_value and month_value and 1 <= month_value <= 12: qs = qs.filter( work_order__work_order_tasks__scheduled_date__year=year_value, work_order__work_order_tasks__scheduled_date__month=month_value, ) if user.is_staff: if work_order_id: qs = qs.filter(work_order_id=work_order_id) return qs.distinct() allowed_orders = _work_orders_queryset_for_user(user).values('id') qs = qs.filter(work_order_id__in=allowed_orders) if work_order_id: qs = qs.filter(work_order_id=work_order_id) return qs.distinct() def get_serializer_context(self): context = super().get_serializer_context() context['request'] = self.request return context @action(detail=True, methods=['get'], url_path='image') def image(self, request, pk=None): invoice = self.get_object() if not invoice.image: raise DRFValidationError({"detail": "Datoteka računa nije dostupna."}) suffix = Path(invoice.image.name or '').suffix.lower() if suffix == '.pdf': invoice.image.open('rb') try: response = HttpResponse(invoice.image.read(), content_type='application/pdf') finally: invoice.image.close() response['Cache-Control'] = 'private, max-age=86400' response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"' return response width = _parse_positive_int( request.query_params.get('w'), field_name='w', default=1280, min_value=160, max_value=3840, ) quality = _parse_positive_int( request.query_params.get('q'), field_name='q', default=75, min_value=30, max_value=95, ) image_format, extension = _parse_format(request.query_params.get('fmt')) try: invoice.image.open('rb') with Image.open(invoice.image) as source: image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() if width and image.width > width: ratio = width / float(image.width) target_height = max(1, int(image.height * ratio)) image = image.resize((width, target_height), Image.Resampling.LANCZOS) buffer = BytesIO() save_kwargs = {"format": image_format, "optimize": True} if image_format in ('WEBP', 'JPEG'): save_kwargs["quality"] = quality image.save(buffer, **save_kwargs) except (UnidentifiedImageError, OSError): raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) finally: invoice.image.close() response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") response['Cache-Control'] = 'private, max-age=86400' response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"' return response def perform_create(self, serializer): work_order = serializer.validated_data.get('work_order') if work_order is None: raise DRFValidationError({"work_order": "Putni nalog je obavezan."}) allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists() if not allowed: raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."}) invoice = serializer.save(created_by=self.request.user) _upsert_additional_cost_row_from_invoice(invoice) _invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices']) try: process_work_order_invoice_ocr.delay(str(invoice.pk)) except KombuOperationalError: process_work_order_invoice_ocr.apply(args=[str(invoice.pk)]) def perform_destroy(self, instance): instance.is_active = False instance.save(update_fields=['is_active']) class ServiceContextNoteViewSet( mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet, ): permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return _service_context_notes_queryset_for_user(self.request.user) def get_serializer_class(self): if self.action == 'create': return ServiceContextNoteCreateSerializer return ServiceContextNoteSerializer def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) payload = serializer.validated_data user = request.user audience = payload.get('audience', 'self') target_users = payload.get('target_users') or [] note_text = payload.get('note') note_date = payload.get('note_date') work_order = payload.get('work_order') task = payload.get('task') is_supervisor = _is_supervisor_user(user) can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False)) if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists(): raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'}) if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id): raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'}) recipients = [] if audience == 'self': recipients = [user] elif audience == 'member': recipients = list(target_users) else: if not can_manage_recipients: raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'}) recipients = list(_active_team_members_queryset()) if not recipients: raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'}) created_notes = [] seen = OrderedDict() for recipient in recipients: seen[str(recipient.id)] = recipient with transaction.atomic(): for recipient in seen.values(): note = ServiceContextNote.objects.create( created_by=user, recipient=recipient, audience_source=audience, work_order=work_order, task=task, note=note_text, note_date=note_date, ) created_notes.append(note) NotificationService.create_notification( recipient=recipient, title='Nova bilješka servisnog konteksta', message=note_text[:255], level='info', metadata={ 'entity_type': 'service_note', 'service_note_id': str(note.id), 'work_order_id': str(work_order.id) if work_order else None, 'task_id': str(task.id) if task else None, 'section': 'dashboard', 'note_date': note_date.isoformat() if note_date else None, }, send_email=False, ) response_serializer = ServiceContextNoteSerializer( _service_context_notes_queryset_for_user(user)[:20], many=True, ) return Response( { 'created_count': len(created_notes), 'notes': response_serializer.data, }, status=status.HTTP_201_CREATED, ) @action(detail=True, methods=['post'], url_path='close') def close(self, request, pk=None): note = self.get_object() if note.is_closed: serializer = ServiceContextNoteSerializer(note) return Response(serializer.data, status=status.HTTP_200_OK) note.is_closed = True note.closed_at = timezone.now() note.save(update_fields=['is_closed', 'closed_at', 'updated_at']) serializer = ServiceContextNoteSerializer(note) return Response(serializer.data, status=status.HTTP_200_OK) class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet): """ List all notifications for the authenticated user and provide an action to mark a notification as read. """ serializer_class = VehicleNotificationSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): # Prikaži samo aktivne notifikacije za trenutnog korisnika user = self.request.user return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at') @action(detail=True, methods=['post'], url_path='mark-read') def mark_read(self, request, pk=None): """ Označi notifikaciju kao pročitanu. URL: POST /api/fleet/notifications/{pk}/mark-read/ """ notif = self.get_object() # sigurnosna provjera: dozvoljeno samo primatelju if notif.recipient != request.user: raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.") notif.is_read = True notif.save(update_fields=['is_read']) serializer = self.get_serializer(notif) return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=['post'], url_path='mark-all-read') def mark_all_read(self, request): """ Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane. URL: POST /api/fleet/notifications/mark-all-read/ """ user = request.user qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False) updated_count = qs.update(is_read=True) return Response({"marked": updated_count}, status=status.HTTP_200_OK) class VehicleServicePhotoViewSet(viewsets.ModelViewSet): """ ViewSet za upravljanje fotografijama servisnih zapisa. Delegira sve operacije u VehicleServicePhotoService. Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka. """ queryset = VehicleServicePhoto.objects.filter(is_active=True) serializer_class = VehicleServicePhotoSerializer permission_classes = [permissions.IsAuthenticated] parser_classes = (MultiPartParser, FormParser) def get_queryset(self): """Filtriraj po service_record ID-u ako je proslijeđen u query parametrima.""" service_record_id = self.request.query_params.get('service_record_id') user = self.request.user qs = VehicleServicePhoto.objects.filter(is_active=True) if not user.is_staff: qs = qs.filter(service_record__performed_by=user) if service_record_id: qs = qs.filter(service_record_id=service_record_id) return qs @action(detail=True, methods=['get'], url_path='optimized') def optimized(self, request, pk=None): photo = self.get_object() if not photo.image: raise DRFValidationError({"detail": "Fotografija nije dostupna."}) width = _parse_positive_int( request.query_params.get('w'), field_name='w', default=1280, min_value=160, max_value=3840, ) quality = _parse_positive_int( request.query_params.get('q'), field_name='q', default=75, min_value=30, max_value=95, ) image_format, extension = _parse_format(request.query_params.get('fmt')) try: photo.image.open('rb') with Image.open(photo.image) as source: image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() if width and image.width > width: ratio = width / float(image.width) target_height = max(1, int(image.height * ratio)) image = image.resize((width, target_height), Image.Resampling.LANCZOS) buffer = BytesIO() save_kwargs = {"format": image_format, "optimize": True} if image_format in ('WEBP', 'JPEG'): save_kwargs["quality"] = quality image.save(buffer, **save_kwargs) except (UnidentifiedImageError, OSError): raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) finally: photo.image.close() response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") response['Cache-Control'] = 'private, max-age=86400' response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"' return response def perform_create(self, serializer): """ Delegiraj stvaranje u VehicleServicePhotoService. serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika. """ try: data = serializer.validated_data if not _can_access_service_record(self.request.user, data.get('service_record')): raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."}) photo = VehicleServicePhotoService.upload_photo( data=data, uploaded_by=self.request.user ) serializer.instance = photo except DRFValidationError: raise except Exception as e: raise DRFValidationError({"detail": str(e)}) def perform_update(self, serializer): """ Delegiraj ažuriranje u VehicleServicePhotoService. """ try: instance = serializer.instance data = serializer.validated_data photo = VehicleServicePhotoService.update_photo( instance=instance, data=data, user=self.request.user ) serializer.instance = photo except DRFValidationError: raise except Exception as e: raise DRFValidationError({"detail": str(e)}) def perform_destroy(self, instance): """ Delegiraj soft-delete u VehicleServicePhotoService. """ try: VehicleServicePhotoService.delete_photo( instance=instance, user=self.request.user ) except DRFValidationError: raise except Exception as e: raise DRFValidationError({"detail": str(e)}) class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet): queryset = VehicleServiceAttachment.objects.filter(is_active=True) serializer_class = VehicleServiceAttachmentSerializer permission_classes = [permissions.IsAuthenticated] parser_classes = (MultiPartParser, FormParser) def get_queryset(self): user = self.request.user service_record_id = self.request.query_params.get('service_record_id') qs = VehicleServiceAttachment.objects.filter(is_active=True) if not user.is_staff: qs = qs.filter(service_record__performed_by=user) if service_record_id: qs = qs.filter(service_record_id=service_record_id) return qs def perform_create(self, serializer): if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')): raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."}) serializer.save(uploaded_by=self.request.user) def perform_destroy(self, instance): instance.is_active = False instance.save(update_fields=['is_active'])