Files
004.erp.generic/backend/modules/fleet/views.py
mariomitte d35eaeccf4
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
feat: dodaj ishodište i PDF račune za putni nalog
Dodano je origin_location polje kroz backend model, serializer, admin i UI
modal za kreiranje/uređivanje putnog naloga (default: Zagreb).

Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend
validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza.

Za greške uploada računa dodan je error toast kako bi poruke validacije bile
vidljive korisniku odmah u sučelju.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:54:15 +02:00

2043 lines
81 KiB
Python

# /backend/modules/fleet/views.py
from io import BytesIO
import base64
from datetime import timedelta
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from django.conf import settings
from django.core.mail import EmailMessage
from django.http import HttpResponse, FileResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.html import escape
from django.db.models import Q
from 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 rest_framework import viewsets, permissions, status
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
from rest_framework.parsers import MultiPartParser, FormParser
from celery.exceptions import TimeoutError as CeleryTimeoutError
from kombu.exceptions import OperationalError as KombuOperationalError
from .models import (
Crane,
Vehicle,
VehicleNotification,
WorkOrder,
WorkOrderPhoto,
GeneratedWorkOrderPdf,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
)
from .serializers import (
CraneSerializer,
VehicleSerializer,
WorkOrderSerializer,
WorkOrderInvoiceSerializer,
WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer,
VehicleNotificationSerializer,
VehicleServicePhotoSerializer,
VehicleServiceAttachmentSerializer,
)
from infrastructure.pusher_service import PusherService
from .services import (
VehicleService,
VehicleServicePhotoService,
NotificationService,
)
from .tasks import (
build_work_order_invoices_pdf_task,
build_work_order_pdf_cached_task,
cleanup_expired_generated_pdfs_task,
)
register_unicode_fonts()
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
if asset_type:
queryset = queryset.filter(asset_type=asset_type)
if user.is_staff or getattr(user, 'is_serviser', False):
return queryset
return queryset
def _service_records_queryset_for_user(user):
queryset = (
VehicleServiceRecord.objects
.select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle')
.prefetch_related('photos', 'attachments')
.all()
)
if user.is_staff:
return queryset
return queryset.filter(performed_by=user)
def _work_orders_queryset_for_user(user):
queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all()
if user.is_staff:
return queryset
if getattr(user, 'is_serviser', False):
return queryset.filter(
Q(creator=user) | Q(vehicle__assigned_servicer=user)
).distinct()
return queryset.filter(creator=user)
def _work_order_related_tasks_queryset(work_order):
from modules.task_management.models import Task
direct_task_ids = list(
work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
)
inferred_task_ids = list(
VehicleServiceRecord.objects.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
task_id__isnull=False,
task__is_active=True,
).values_list('task_id', flat=True)
)
task_ids = list({*direct_task_ids, *inferred_task_ids})
if not task_ids:
return Task.objects.none()
return Task.objects.filter(id__in=task_ids, is_active=True).select_related(
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
).order_by('-created_at')
def _can_access_service_record(user, service_record):
service_record_id = getattr(service_record, 'pk', service_record)
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
def _parse_positive_int(value, *, field_name, default, min_value, max_value):
if value in (None, ''):
return default
try:
parsed = int(value)
except (TypeError, ValueError):
raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."})
if parsed < min_value or parsed > max_value:
raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."})
return parsed
def _parse_format(value):
selected = (value or 'webp').lower()
supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'}
if selected not in supported:
raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."})
extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
return supported[selected], extension
GENERATED_PDF_TTL_HOURS = 24
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
def _pdf_filename(work_order, pdf_type):
display_code = _work_order_display_code(work_order)
if pdf_type == 'invoices':
return f"{display_code}.work-order-invoices.pdf"
if pdf_type == 'service_records':
return f"{display_code}.work-order-service-records.pdf"
return f"{display_code}.work-order.pdf"
def _get_cached_pdf(work_order, pdf_type):
now = timezone.now()
expected_filename = _pdf_filename(work_order, pdf_type)
candidates = list(
GeneratedWorkOrderPdf.objects
.filter(
is_active=True,
work_order=work_order,
pdf_type=pdf_type,
status='ready',
expires_at__gt=now,
)
.exclude(file='')
.exclude(file__isnull=True)
.order_by('-created_at')
)
for candidate in candidates:
if str(candidate.filename or '').strip() == expected_filename:
return candidate
return None
def _cleanup_expired_generated_pdfs():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
def _cached_pdf_file_response(generated_pdf, *, default_filename):
generated_pdf.file.open('rb')
filename = generated_pdf.filename or default_filename
response = FileResponse(generated_pdf.file, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Cache-Control'] = 'private, max-age=3600'
return response
def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None):
if user is None:
return
if doc_type == 'work_order':
doc_label = "putnog naloga"
elif doc_type == 'service_records':
doc_label = "servisnih zapisa putnog naloga"
else:
doc_label = "računa putnog naloga"
display_code = _work_order_display_code(work_order)
if stage == 'requested':
title = f"Zahtjev za PDF {doc_label}"
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za putni nalog {display_code}."
level = "info"
elif stage == 'failed':
title = f"Greška kod PDF-a ({doc_label})"
message = f"Generiranje PDF dokumenta nije uspjelo za putni nalog {display_code}."
level = "warning"
else:
title = f"PDF spreman ({doc_label})"
message = f"PDF dokument je uspješno generiran za putni nalog {display_code}."
level = "success"
metadata = {
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": doc_type,
"stage": stage,
}
if generated_pdf and generated_pdf.pk:
metadata["generated_pdf_id"] = str(generated_pdf.pk)
metadata["download_url"] = (
f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/"
)
metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type)
if generated_pdf.expires_at:
metadata["expires_at"] = generated_pdf.expires_at.isoformat()
NotificationService.create_notification(
recipient=user,
title=title,
message=message,
level=level,
send_email=False,
metadata=metadata,
)
def _request_cached_pdf_generation(*, request, work_order, pdf_type):
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, pdf_type)
if cached:
_notify_pdf_request(
user=request.user,
work_order=work_order,
doc_type=pdf_type,
stage='completed',
generated_pdf=cached,
)
return {
"status": "ready",
"generated_pdf_id": str(cached.pk),
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/",
"filename": cached.filename or _pdf_filename(work_order, pdf_type),
"expires_at": cached.expires_at.isoformat() if cached.expires_at else None,
}
existing_pending = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=work_order,
pdf_type=pdf_type,
status='pending',
).order_by('-created_at').first()
if existing_pending:
return {
"status": "processing",
"generated_pdf_id": str(existing_pending.pk),
}
generated_pdf = GeneratedWorkOrderPdf.objects.create(
work_order=work_order,
requested_by=request.user,
pdf_type=pdf_type,
status='pending',
filename=_pdf_filename(work_order, pdf_type),
expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS),
)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested')
try:
build_work_order_pdf_cached_task.delay(str(generated_pdf.pk))
cleanup_expired_generated_pdfs_task.delay()
except KombuOperationalError:
build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get()
cleanup_expired_generated_pdfs_task.apply().get()
return {
"status": "processing",
"generated_pdf_id": str(generated_pdf.pk),
}
def _build_simple_pdf(title, rows):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 50
pdf.setFont("Vera-Bold", 14)
pdf.drawString(40, y, title)
y -= 24
pdf.setFont("Vera", 10)
for row in rows:
text = f"{row[0]}: {row[1]}"
if y < 60:
pdf.showPage()
pdf.setFont("Vera", 10)
y = height - 50
pdf.drawString(40, y, text[:140])
y -= 16
pdf.save()
return buffer.getvalue()
def _draw_pdf_row(pdf, *, label, value, y, width, height):
if y < 60:
pdf.showPage()
pdf.setFont("Vera", 10)
y = height - 50
text = f"{label}: {value}"
pdf.drawString(40, y, text[:180])
return y - 16
def _build_work_order_pdf(work_order):
vehicle = work_order.vehicle
creator = work_order.creator
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
now_local = timezone.localtime(timezone.now())
tz = timezone.get_current_timezone()
display_code = _work_order_display_code(work_order)
def _fmt_date(value):
if not value:
return '-'
if hasattr(value, 'strftime'):
return value.strftime('%d.%m.%Y.')
return str(value)
def _fmt_time(value):
if not value:
return '-'
return value.astimezone(tz).strftime('%H:%M')
def _fmt_eur(value):
try:
return f"{float(value):.2f}"
except (TypeError, ValueError):
return "0,00 €"
def _paragraph(text, *, bold=False, size=9, align=0):
styles = getSampleStyleSheet()
base = styles['BodyText'].clone('wo-p')
base.fontName = 'Vera-Bold' if bold else 'Vera'
base.fontSize = size
base.leading = size + 1.5
base.alignment = align
return Paragraph(text, base)
def _hours_between(start_at, end_at):
if not start_at or not end_at:
return 0.0
delta = end_at - start_at
return max(0.0, round(delta.total_seconds() / 3600.0, 2))
company_name = getattr(getattr(vehicle, 'client', None), 'name', None) or "KNEZ LJUBO d.o.o."
creator_name = _user_display_name(creator) or creator.email
creator_occupation = (getattr(creator, 'occupation', None) or '').strip() or (
"Servisni inženjer" if getattr(creator, 'is_serviser', False) else "Djelatnik"
)
creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
travel_start = work_order.travel_start_at
travel_end = work_order.travel_end_at
travel_hours = _hours_between(travel_start, travel_end)
daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
daily_rate = 0.0
daily_total = daily_qty * daily_rate
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
grand_total = daily_total + transport_total
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
assigned_servicer_vehicle = (
creator.assigned_vehicles
.filter(asset_type='vehicle', is_active=True)
.order_by('registration_number')
.first()
)
assigned_servicer_vehicle_label = "-"
assigned_servicer_vehicle_registration = "-"
if assigned_servicer_vehicle:
assigned_servicer_vehicle_label = " ".join(
part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part
).strip() or assigned_servicer_vehicle.registration_number
assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-"
servicer_vehicle_label = (
assigned_servicer_vehicle_label
if assigned_servicer_vehicle
else (work_order.servicer_vehicle_make_model or '-')
)
servicer_vehicle_registration = (
assigned_servicer_vehicle_registration
if assigned_servicer_vehicle
else (work_order.servicer_vehicle_registration or '-')
)
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
page_w, page_h = A4
margin = 16
content_w = page_w - (2 * margin)
y = page_h - 18
def draw_table(y_pos, data, widths, styles, row_heights=None):
table = Table(data, colWidths=widths, rowHeights=row_heights)
table.setStyle(TableStyle(styles))
_, table_h = table.wrap(content_w, page_h)
if y_pos - table_h < 28:
pdf.showPage()
y_pos = page_h - 18
table.drawOn(pdf, margin, y_pos - table_h)
return y_pos - table_h - 4
# 1) Zaglavlje + određujem da
section_1_top = [[
_paragraph(f"Trgovačko društvo<br/><b>{company_name}</b>", size=8.8),
_paragraph("N A L O G<br/>ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
_paragraph(
f"Mjesto i datum:<br/><b>{place_label} &nbsp;&nbsp; {_fmt_date(now_local.date())}</b><br/>"
f"Broj naloga:<br/><b>{display_code}</b>",
size=8.8,
),
]]
y = draw_table(
y,
section_1_top,
[content_w * 0.305, content_w * 0.35, content_w * 0.345],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
],
row_heights=[44],
)
y = draw_table(
y,
[[_paragraph("Određujem da:", size=11)]],
[content_w],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
],
row_heights=[14],
)
y = draw_table(
y,
[[
_paragraph(f"Ime i prezime:<br/><b>{creator_name}</b><br/><br/>Prebivalište:<br/><b>{creator_residence}</b>", size=8.7),
_paragraph(f"Zanimanje:<br/><b>{creator_occupation}</b><br/><br/>Na radnom mjestu:<br/><b>{creator_work_position}</b>", size=8.7),
]],
[content_w * 0.66, content_w * 0.34],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
],
row_heights=[58],
)
# 2) Putuje dana / mjesto / zadatak
y = draw_table(
y,
[
["Službeno otputuje dana:", "U mjesto:", "Sa zadaćom:"],
[
"1 dana" if travel_start else "-",
work_order.location or '-',
work_order.purpose or '-',
],
],
[content_w / 3, content_w / 3, content_w / 3],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.6),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
],
row_heights=[14, 19],
)
# 3) Trajanje + kilometraže
y = draw_table(
y,
[
["Putovanje može trajati:", "Datum polaska:", "Odobravamo upotrebu vozila:"],
[
"1 dana" if travel_start else "-",
_fmt_date(travel_start.date() if travel_start else work_order.date),
f"{servicer_vehicle_label}, {servicer_vehicle_registration}",
],
["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
[
company_name,
"-",
str(work_order.servicer_vehicle_start_mileage or work_order.start_mileage or '-'),
],
["", "", "Završno stanje kilometara:"],
["", "", str(work_order.servicer_vehicle_end_mileage or work_order.end_mileage or '-')],
],
[content_w / 3, content_w / 3, content_w / 3],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
('FONTNAME', (0, 3), (2, 3), 'Vera-Bold'),
('FONTNAME', (2, 5), (2, 5), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.5),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
],
row_heights=[14, 18, 14, 18, 12, 14],
)
# 4) Obračun putnih troškova
section4_top = [
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
[
_fmt_date(travel_start.date() if travel_start else work_order.date),
_fmt_time(travel_start),
_fmt_date(travel_end.date() if travel_end else work_order.date),
_fmt_time(travel_end),
str(travel_hours).replace('.', ','),
str(daily_qty).replace('.', ','),
_fmt_eur(daily_rate),
_fmt_eur(daily_total),
],
]
for _ in range(3):
section4_top.append(["", "", "", "", "", "", "", ""])
y = draw_table(
y,
section4_top,
[content_w * 0.12, content_w * 0.09, content_w * 0.12, content_w * 0.09, content_w * 0.11, content_w * 0.13, content_w * 0.17, content_w * 0.17],
[
('SPAN', (0, 0), (7, 0)),
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (7, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (7, 1), 'Vera'),
('FONTNAME', (0, 2), (7, 2), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('ALIGN', (0, 0), (7, 0), 'CENTER'),
('ALIGN', (0, 1), (7, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
],
row_heights=[15, 14, 14, 12, 12, 12],
)
y = draw_table(
y,
[
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
[origin_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
["", "", "", "", "", ""],
["", "", "", "", "", ""],
],
[content_w * 0.18, content_w * 0.18, content_w * 0.20, content_w * 0.10, content_w * 0.17, content_w * 0.17],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
],
row_heights=[14, 14, 12, 12],
)
# 5) Dodatni troškovi
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
for inv in invoices[:5]:
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
while len(additional_rows) < 7:
additional_rows.append(["", "", ""])
y = draw_table(
y,
additional_rows,
[content_w * 0.53, content_w * 0.30, content_w * 0.17],
[
('SPAN', (0, 0), (2, 0)),
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.4),
('ALIGN', (0, 0), (2, 0), 'CENTER'),
('ALIGN', (2, 1), (2, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
],
row_heights=[14, 14, 12, 12, 12, 12, 12],
)
# 6) Prilozi + ukupna isplata
y = draw_table(
y,
[[
_paragraph(f"Prilozi:<br/>{attachments_text}", size=8.5),
_paragraph(
f"Ukupno za isplatu: <b>{_fmt_eur(grand_total)}</b><br/>"
f"Isplaćeno: <b>{_fmt_eur(0)}</b><br/>"
f"Ostaje za isplatu: <b>{_fmt_eur(grand_total)}</b>",
size=9,
),
]],
[content_w * 0.53, content_w * 0.47],
[
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
],
row_heights=[48],
)
y -= 16
# Potpisi
if y < 64:
pdf.showPage()
y = page_h - 64
pdf.setFont("Vera", 9)
pdf.drawString(margin + 48, y, f"U {place_label} dana {_fmt_date(now_local.date())}")
y -= 20
sig_w = (content_w - 30) / 3
sig_labels = ["(Likvidator)", "(Podnositelj računa)", "(Isplatio blagajnik)"]
for i, label in enumerate(sig_labels):
x = margin + 10 + (i * sig_w)
pdf.line(x, y, x + sig_w - 24, y)
pdf.drawCentredString(x + (sig_w - 24) / 2, y - 12, label)
y -= 26
pdf.line(margin + 10, y, margin + sig_w - 14, y)
pdf.drawCentredString(margin + (sig_w - 4) / 2, y - 12, "(Primalac)")
pdf.line(margin + sig_w + 10, y, margin + 2 * sig_w - 14, y)
pdf.drawCentredString(margin + sig_w + (sig_w - 4) / 2, y - 12, "(Potpis direktora)")
pdf.drawString(margin + (2 * sig_w) + 34, y + 4, "M.P.")
pdf.save()
return buffer.getvalue()
def _build_work_order_service_records_pdf(work_order):
vehicle = work_order.vehicle
client_name = getattr(vehicle.client, 'name', None) or '-'
manufacturer_str = str(vehicle.make or '-')
model_str = str(vehicle.model or '-')
serial_str = str(vehicle.crane_serial_number or '-')
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
nalog_str = _work_order_display_code(work_order)
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
FOOTER_H = 72
MARGIN = 28
service_rows = list(
VehicleServiceRecord.objects.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
)
.select_related('performed_by', 'task')
.prefetch_related('photos')
.order_by('service_date', 'created_at')
)
related_tasks = list(_work_order_related_tasks_queryset(work_order))
task_titles = [row.task.title for row in service_rows if getattr(row, 'task', None) and row.task.title]
notes_text = ' | '.join(task_titles[:2]) if task_titles else (work_order.notes or '')
buffer = BytesIO()
class PageCanvas(canvas.Canvas):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._current_page = 0
def showPage(self):
self._current_page += 1
self._draw_header_footer()
super().showPage()
def save(self):
self._current_page += 1
self._draw_header_footer()
super().save()
def _draw_header_footer(self):
page_num = self._current_page
draw_standard_header_footer(
self,
page_num=page_num,
client_name=client_name,
manufacturer=manufacturer_str,
model=model_str,
serial=serial_str,
upgrade_hours=upgrade_hours_str,
chassis_hours=chassis_hours_str,
mileage=mileage_str,
work_order_number=nalog_str,
generated_date=generated_date_str,
report_title="Izvještaj servisera",
page_size=A4,
margin=MARGIN,
header_h=HEADER_H,
footer_h=FOOTER_H,
)
pdf = PageCanvas(buffer, pagesize=A4)
page_w, page_h = A4
content_top = page_h - HEADER_H - 10
content_bottom = FOOTER_H + 52
content_width = page_w - 2 * MARGIN
def new_page():
pdf.showPage()
return content_top
def draw_table(y, data, col_widths, style, row_heights=None):
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
table.setStyle(style)
_, h = table.wrap(content_width, page_h)
if y - h < content_bottom:
y = new_page()
table.drawOn(pdf, MARGIN, y - h)
return y - h - 10
paragraph_styles = getSampleStyleSheet()
description_style = paragraph_styles['BodyText'].clone('service-description')
description_style.fontName = 'Vera'
description_style.fontSize = 8.2
description_style.leading = 10.4
def _normalize_hours_table_row(row):
if not isinstance(row, dict):
return None
return {
'day': str(row.get('day', '') or '').strip() or '-',
'date': str(row.get('date', '') or '').strip() or '-',
'work_time': " - ".join(
[
str(row.get('work_time_from', '') or '').strip() or '-',
str(row.get('work_time_to', '') or '').strip() or '-',
]
),
'travel_time': " - ".join(
[
str(row.get('travel_time_from', '') or '').strip() or '-',
str(row.get('travel_time_to', '') or '').strip() or '-',
]
),
'break_hours': str(row.get('break_hours', '') or '').strip() or '-',
'work_hours': str(row.get('work_hours', '') or '').strip() or '-',
'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-',
'places': "\n".join([
f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}",
f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}",
]),
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
}
def _parse_decimal(value):
if value in (None, ''):
return 0.0
try:
return float(str(value).replace(',', '.'))
except (TypeError, ValueError):
return 0.0
def _format_decimal(value):
return f"{float(value):.1f}".replace('.', ',')
normalized_hours_rows = []
for task in related_tasks:
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
if not isinstance(table_data, dict):
continue
rows = table_data.get('rows', [])
if not isinstance(rows, list):
continue
for raw_row in rows:
normalized = _normalize_hours_table_row(raw_row)
if normalized:
normalized_hours_rows.append(normalized)
y = content_top
table1 = [
["Naziv Tvrtke:", client_name, "Lokacija intervencije:", work_order.location or '-'],
["Serviser:", _user_display_name(work_order.creator) or '-', "Asistirao:", '-'],
]
y = draw_table(
y,
table1,
[95, 165, 110, content_width - 370],
TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.5),
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
]),
)
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
table2 = [
["Prijevozno sredstvo:", "Registracija:", "Broj narudžbe klijenta:", "Posao završen"],
[work_order.servicer_vehicle_make_model or '-', work_order.servicer_vehicle_registration or '-', "-", completion_label],
]
y = draw_table(
y,
table2,
[152, 100, 147, content_width - 399],
TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
]),
row_heights=[17, 17],
)
table3_headers = [
"Dan",
"Datum",
"Vrijeme rada\nod-do",
"Vrijeme putovanja\nod-do",
"Pauza\nh",
"Sati\nrada",
"Sati\nputa",
"Mjesto polaska / dolaska",
"Kilometri\nvozila",
]
table3_rows = [table3_headers]
total_hours_travel = 0.0
total_vehicle_km = 0.0
if normalized_hours_rows:
for row in normalized_hours_rows:
table3_rows.append([
row['day'],
row['date'],
row['work_time'],
row['travel_time'],
row['break_hours'],
row['work_hours'],
row['travel_hours'],
row['places'],
row['vehicle_km'],
])
total_hours_travel += _parse_decimal(row['travel_hours'])
total_vehicle_km += _parse_decimal(row['vehicle_km'])
else:
for _ in range(12):
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')])
y = draw_table(
y,
table3_rows,
[28, 54, 72, 72, 36, 40, 40, 97, content_width - 439],
TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 7.8),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, -1), 'CENTER'),
('ALIGN', (1, 0), (6, -1), 'CENTER'),
('SPAN', (0, -1), (5, -1)),
('ALIGN', (0, -1), (5, -1), 'LEFT'),
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('LEFTPADDING', (0, -1), (5, -1), 12),
('VALIGN', (7, 1), (7, -2), 'TOP'),
('ALIGN', (7, 1), (7, -2), 'LEFT'),
]),
)
if y < content_bottom + 24:
y = new_page()
pdf.setFont("Vera", 9)
pdf.drawString(MARGIN, y, f"Napomene: {notes_text or '-'}")
# Sljedeća stranica: servisni zapisi.
y = new_page()
y -= 14
pdf.setFont("Vera-Bold", 12)
pdf.drawString(MARGIN, y, "Servisni zapisi")
y -= 18
if not related_tasks:
pdf.setFont("Vera", 9)
pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.")
else:
for task in related_tasks:
task_records = [
row for row in service_rows
if getattr(row, 'task_id', None) == task.id
]
if not task_records:
continue
if y < content_bottom + 60:
y = new_page()
pdf.setFont("Vera-Bold", 10)
pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95])
y -= 14
for record in task_records:
if y < content_bottom + 64:
y = new_page()
description_table = [[Paragraph(record.description or '-', description_style)]]
y = draw_table(
y,
[["Servisni opis"], *description_table],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
parts_table = [[Paragraph(record.parts or '-', description_style)]]
y = draw_table(
y,
[["Korišteni dijelovi"], *parts_table],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image]
if not photos:
continue
if y < content_bottom + 30:
y = new_page()
y -= 8
pdf.setFont("Vera", 8)
pdf.drawString(MARGIN, y, "Fotografije:")
y -= 10
image_gap = 10
max_image_height = 110
image_width = (content_width - image_gap) / 2
for index in range(0, len(photos), 2):
row_photos = photos[index:index + 2]
prepared = []
for photo in row_photos:
try:
image_reader = ImageReader(photo.image)
source_w, source_h = image_reader.getSize()
if not source_w or not source_h:
continue
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
prepared.append((photo, image_reader, scaled_h))
except Exception:
continue
if not prepared:
continue
caption_height = 12
row_height = max(item[2] for item in prepared) + caption_height
if y - row_height < content_bottom:
y = new_page()
base_y = y - caption_height
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
image_x = MARGIN + photo_index * (image_width + image_gap)
image_y = base_y - scaled_h
pdf.drawImage(
image_reader,
image_x,
image_y,
width=image_width,
height=scaled_h,
preserveAspectRatio=True,
anchor='c',
mask='auto',
)
pdf.setFont("Vera", 7)
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
pdf.drawString(image_x, image_y - 10, caption[:52])
y = base_y - max(item[2] for item in prepared) - 14
pdf.save()
return buffer.getvalue()
def _build_service_record_pdf(service_record):
vehicle = service_record.vehicle
task = getattr(service_record, 'task', None)
work_order = getattr(task, 'work_order', None) if task else None
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
manufacturer_str = str(vehicle.make or '-')
model_str = str(vehicle.model or '-')
serial_str = str(vehicle.crane_serial_number or '-')
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
nalog_str = _work_order_display_code(work_order) if work_order else '-'
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
FOOTER_H = 72
MARGIN = 28
def _fmt_date(value):
if not value:
return '-'
return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value)
buffer = BytesIO()
class PageCanvas(canvas.Canvas):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._current_page = 0
def showPage(self):
self._current_page += 1
self._draw_header_footer()
super().showPage()
def save(self):
self._current_page += 1
self._draw_header_footer()
super().save()
def _draw_header_footer(self):
page_num = self._current_page
draw_standard_header_footer(
self,
page_num=page_num,
client_name=client_name,
manufacturer=manufacturer_str,
model=model_str,
serial=serial_str,
upgrade_hours=upgrade_hours_str,
chassis_hours=chassis_hours_str,
mileage=mileage_str,
work_order_number=nalog_str,
generated_date=generated_date_str,
report_title="Izvještaj servisera",
page_size=A4,
margin=MARGIN,
header_h=HEADER_H,
footer_h=FOOTER_H,
)
pdf = PageCanvas(buffer, pagesize=A4)
page_w, page_h = A4
content_top = page_h - HEADER_H - 10
content_bottom = FOOTER_H + 52
content_width = page_w - 2 * MARGIN
def new_page():
pdf.showPage()
return content_top
def draw_table(y, data, col_widths, style, row_heights=None):
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
table.setStyle(style)
_, h = table.wrap(content_width, page_h)
if y - h < content_bottom:
y = new_page()
table.drawOn(pdf, MARGIN, y - h)
return y - h - 10
paragraph_styles = getSampleStyleSheet()
description_style = paragraph_styles['BodyText'].clone('service-record-description')
description_style.fontName = 'Vera'
description_style.fontSize = 8.2
description_style.leading = 10.4
y = content_top - 6
y -= 6
info_rows = [
["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)],
["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'],
["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'],
["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')],
["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')],
]
y = draw_table(
y,
info_rows,
[78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)],
TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.5),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
y = draw_table(
y,
[["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
y = draw_table(
y,
[["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
attachments = list(service_record.attachments.filter(is_active=True).all())
attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file]
y = draw_table(
y,
[["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image]
if photos:
if y < content_bottom + 30:
y = new_page()
y -= 8
pdf.setFont("Vera", 8)
pdf.drawString(MARGIN, y, "Fotografije:")
y -= 10
image_gap = 10
max_image_height = 110
image_width = (content_width - image_gap) / 2
for index in range(0, len(photos), 2):
row_photos = photos[index:index + 2]
prepared = []
for photo in row_photos:
try:
image_reader = ImageReader(photo.image)
source_w, source_h = image_reader.getSize()
if not source_w or not source_h:
continue
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
prepared.append((photo, image_reader, scaled_h))
except (OSError, ValueError, TypeError, UnidentifiedImageError):
continue
if not prepared:
continue
caption_height = 12
row_height = max(item[2] for item in prepared) + caption_height
if y - row_height < content_bottom:
y = new_page()
base_y = y - caption_height
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
image_x = MARGIN + photo_index * (image_width + image_gap)
image_y = base_y - scaled_h
pdf.drawImage(
image_reader,
image_x,
image_y,
width=image_width,
height=scaled_h,
preserveAspectRatio=True,
anchor='c',
mask='auto',
)
pdf.setFont("Vera", 7)
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
pdf.drawString(image_x, image_y - 10, caption[:52])
y = base_y - max(item[2] for item in prepared) - 14
else:
y = draw_table(
y,
[["Fotografije"], ["-"]],
[content_width],
TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
('FONTSIZE', (0, 0), (-1, -1), 8.2),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]),
)
pdf.save()
return buffer.getvalue()
def _first_non_empty(*values):
for value in values:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _user_display_name(user):
if not user:
return None
full_name = user.get_full_name()
if full_name:
return full_name
if getattr(user, 'email', None):
return user.email
if getattr(user, 'username', None):
return user.username
return str(getattr(user, 'pk', ''))
def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
if not recipient:
raise DRFValidationError({"recipient": "Email primatelja je obavezan."})
message = EmailMessage(
subject=subject,
body=body,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None),
to=[recipient],
)
message.attach(filename, pdf_bytes, 'application/pdf')
message.send(fail_silently=False)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def pusher_auth(request):
"""
Authenticate user for private Pusher channel.
Endpoint koji frontend poziva za auth na privatnom kanalu.
"""
socket_id = request.data.get('socket_id')
channel_name = request.data.get('channel_name')
if not socket_id or not channel_name:
return Response({'error': 'Missing socket_id or channel_name'}, status=400)
auth_data = PusherService.authenticate_channel(
socket_id=socket_id,
channel_name=channel_name,
user_id=request.user.id
)
if not auth_data:
return Response({'error': 'Authentication failed'}, status=403)
return Response(auth_data)
class VehicleViewSet(viewsets.ModelViewSet):
serializer_class = VehicleSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle')
class CraneViewSet(viewsets.ModelViewSet):
serializer_class = CraneSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane')
class WorkOrderViewSet(viewsets.ModelViewSet):
serializer_class = WorkOrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return _work_orders_queryset_for_user(self.request.user)
@action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser])
def images(self, request, pk=None):
work_order = self.get_object()
if request.method.lower() == 'post':
serializer = WorkOrderPhotoSerializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
serializer.save(work_order=work_order, uploaded_by=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
image_format, _ = _parse_format(request.query_params.get('fmt'))
photos_qs = WorkOrderPhoto.objects.filter(
is_active=True,
work_order_id=work_order.pk,
).select_related('uploaded_by').order_by('-created_at')
images = []
for photo in photos_qs:
file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk})
original_url = request.build_absolute_uri(file_path)
optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}")
images.append({
"id": photo.pk,
"work_order_id": photo.work_order_id,
"description": photo.description,
"original_url": original_url,
"optimized_url": optimized_url,
"created_at": photo.created_at,
})
return Response({
"work_order_id": work_order.pk,
"vehicle_id": work_order.vehicle_id,
"image_quality": quality,
"image_width": width,
"image_format": image_format.lower(),
"images": images,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get'], url_path=r'images/(?P<photo_id>[^/.]+)/file')
def image_file(self, request, pk=None, photo_id=None):
work_order = self.get_object()
photo = WorkOrderPhoto.objects.filter(
is_active=True,
work_order_id=work_order.pk,
pk=photo_id,
).first()
if photo is None or not photo.image:
raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."})
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
photo.image.open('rb')
with Image.open(photo.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
photo.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"'
return response
@action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'work_order')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested')
pdf_bytes = _build_work_order_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
return response
@action(detail=True, methods=['post'], url_path='pdf-request')
def pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path='service-records-pdf')
def service_records_pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'service_records')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
pdf_bytes = _build_work_order_service_records_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
def service_records_pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path='invoices-pdf')
def invoices_pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'invoices')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested')
try:
task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
payload = task_result.get(timeout=45)
except KombuOperationalError:
payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get()
except CeleryTimeoutError:
raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."})
if not isinstance(payload, dict) or payload.get('error'):
raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."})
pdf_b64 = payload.get('pdf_base64')
if not pdf_b64:
raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."})
try:
pdf_bytes = base64.b64decode(pdf_b64)
except (ValueError, TypeError):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
filename = payload.get('filename') or _pdf_filename(work_order, 'invoices')
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=True, methods=['post'], url_path='invoices-pdf-request')
def invoices_pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P<pdf_id>[^/.]+)/download')
def generated_pdf_download(self, request, pk=None, pdf_id=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
generated_pdf = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=work_order,
pk=pdf_id,
status='ready',
expires_at__gt=timezone.now(),
).exclude(file='').exclude(file__isnull=True).first()
if generated_pdf is None:
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
@action(detail=True, methods=['get'], url_path='pdf-preview')
def pdf_preview(self, request, pk=None):
work_order = self.get_object()
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
invoice_rows = []
for invoice in invoices:
image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None
datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
invoice_rows.append(
f"""
<tr>
<td>{escape(invoice.naziv_racuna)}</td>
<td>{escape(invoice.lokacija or '-')}</td>
<td>{escape(datum)}</td>
<td>{escape(invoice.opis or '-')}</td>
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori dokument</a>' if image_url else '-'}</td>
</tr>
"""
)
pdf_url = request.build_absolute_uri(reverse('workorder-pdf', kwargs={'pk': work_order.pk}))
invoices_pdf_url = request.build_absolute_uri(reverse('workorder-invoices-pdf', kwargs={'pk': work_order.pk}))
html = f"""
<!doctype html>
<html lang="hr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Računi putnog naloga {_work_order_display_code(work_order)}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 24px; color: #111827; background: #f8fafc; }}
.card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 16px; }}
h1 {{ margin-top: 0; font-size: 22px; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ border-bottom: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 14px; vertical-align: top; }}
th {{ background: #f3f4f6; font-weight: 600; }}
.actions a {{ display: inline-block; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: 600; }}
.muted {{ color: #6b7280; }}
</style>
</head>
<body>
<div class="card">
<h1>Putni nalog {_work_order_display_code(work_order)} — Računi</h1>
<p class="muted">Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}</p>
<div class="actions">
<a href="{escape(pdf_url)}">Preuzmi PDF putnog naloga</a>
<a href="{escape(invoices_pdf_url)}" style="margin-left:8px;background:#0ea5e9;">Preuzmi PDF računa</a>
</div>
</div>
<div class="card">
<h2>Popis računa</h2>
{('<table><thead><tr><th>naziv_računa</th><th>lokacija</th><th>datum</th><th>opis</th><th>slika</th></tr></thead><tbody>' + ''.join(invoice_rows) + '</tbody></table>') if invoice_rows else '<p class="muted">Za ovaj putni nalog još nema unesenih računa.</p>'}
</div>
</body>
</html>
"""
return HttpResponse(html, content_type='text/html; charset=utf-8')
@action(detail=True, methods=['get'], url_path='task-service-context')
def task_service_context(self, request, pk=None):
work_order = self.get_object()
tasks = _work_order_related_tasks_queryset(work_order).prefetch_related(
'service_records',
'service_records__photos',
'service_records__performed_by',
'service_records__vehicle',
)
payload = []
for task in tasks:
records_payload = []
for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all():
photos_payload = []
for photo in record.photos.filter(is_active=True).all():
if not photo.image:
continue
optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
photos_payload.append({
'id': photo.pk,
'image_url': request.build_absolute_uri(photo.image.url),
'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"),
'description': photo.description,
})
records_payload.append({
'id': record.pk,
'service_title': record.service_title,
'description': record.description,
'service_date': record.service_date,
'cost': str(record.cost),
'mileage': record.mileage,
'performed_by_name': _user_display_name(record.performed_by),
'vehicle_registration': getattr(record.vehicle, 'registration_number', None),
'photos': photos_payload,
})
payload.append({
'id': task.pk,
'title': task.title,
'status': task.status,
'description': task.description,
'assigned_to_name': _user_display_name(task.assigned_to),
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None),
'service_records': records_payload,
})
return Response({
'work_order_id': work_order.pk,
'tasks': payload,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
work_order = self.get_object()
recipient = _first_non_empty(
request.data.get('recipient'),
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
getattr(work_order.creator, 'email', None),
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_work_order_pdf(work_order)
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga."
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=_pdf_filename(work_order, 'work_order'),
pdf_bytes=pdf_bytes,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
serializer_class = VehicleServiceRecordSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return _service_records_queryset_for_user(user)
@action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None):
service_record = self.get_object()
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')}."
)
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=f"{service_record.pk}.service-record.pdf",
pdf_bytes=pdf_bytes,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
serializer_class = WorkOrderInvoiceSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
user = self.request.user
work_order_id = self.request.query_params.get('work_order_id')
qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
if user.is_staff:
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
allowed_orders = _work_orders_queryset_for_user(user).values('id')
qs = qs.filter(work_order_id__in=allowed_orders)
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
def 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": "Slika 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."})
serializer.save(created_by=self.request.user)
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
"""
List all notifications for the authenticated user and provide an action
to mark a notification as read.
"""
serializer_class = VehicleNotificationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Prikaži samo aktivne notifikacije za trenutnog korisnika
user = self.request.user
return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at')
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
"""
Označi notifikaciju kao pročitanu.
URL: POST /api/fleet/notifications/{pk}/mark-read/
"""
notif = self.get_object()
# sigurnosna provjera: dozvoljeno samo primatelju
if notif.recipient != request.user:
raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.")
notif.is_read = True
notif.save(update_fields=['is_read'])
serializer = self.get_serializer(notif)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=['post'], url_path='mark-all-read')
def mark_all_read(self, request):
"""
Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane.
URL: POST /api/fleet/notifications/mark-all-read/
"""
user = request.user
qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False)
updated_count = qs.update(is_read=True)
return Response({"marked": updated_count}, status=status.HTTP_200_OK)
class VehicleServicePhotoViewSet(viewsets.ModelViewSet):
"""
ViewSet za upravljanje fotografijama servisnih zapisa.
Delegira sve operacije u VehicleServicePhotoService.
Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka.
"""
queryset = VehicleServicePhoto.objects.filter(is_active=True)
serializer_class = VehicleServicePhotoSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
"""Filtriraj po service_record ID-u ako je proslijeđen u query parametrima."""
service_record_id = self.request.query_params.get('service_record_id')
user = self.request.user
qs = VehicleServicePhoto.objects.filter(is_active=True)
if not user.is_staff:
qs = qs.filter(service_record__performed_by=user)
if service_record_id:
qs = qs.filter(service_record_id=service_record_id)
return qs
@action(detail=True, methods=['get'], url_path='optimized')
def optimized(self, request, pk=None):
photo = self.get_object()
if not photo.image:
raise DRFValidationError({"detail": "Fotografija nije dostupna."})
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
photo.image.open('rb')
with Image.open(photo.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
photo.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"'
return response
def perform_create(self, serializer):
"""
Delegiraj stvaranje u VehicleServicePhotoService.
serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika.
"""
try:
data = serializer.validated_data
if not _can_access_service_record(self.request.user, data.get('service_record')):
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
photo = VehicleServicePhotoService.upload_photo(
data=data,
uploaded_by=self.request.user
)
serializer.instance = photo
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
def perform_update(self, serializer):
"""
Delegiraj ažuriranje u VehicleServicePhotoService.
"""
try:
instance = serializer.instance
data = serializer.validated_data
photo = VehicleServicePhotoService.update_photo(
instance=instance,
data=data,
user=self.request.user
)
serializer.instance = photo
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
def perform_destroy(self, instance):
"""
Delegiraj soft-delete u VehicleServicePhotoService.
"""
try:
VehicleServicePhotoService.delete_photo(
instance=instance,
user=self.request.user
)
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet):
queryset = VehicleServiceAttachment.objects.filter(is_active=True)
serializer_class = VehicleServiceAttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
user = self.request.user
service_record_id = self.request.query_params.get('service_record_id')
qs = VehicleServiceAttachment.objects.filter(is_active=True)
if not user.is_staff:
qs = qs.filter(service_record__performed_by=user)
if service_record_id:
qs = qs.filter(service_record_id=service_record_id)
return qs
def perform_create(self, serializer):
if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')):
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
serializer.save(uploaded_by=self.request.user)
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])