- Dodano polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy) - Migracije za kreiranje polja i backfill postojećih naloga - Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code - Backend tasks za auto-generiranje display_code pri kreiranju - Frontend komponente ažurirane za prikaz display_code umjesto UUID: * FleetDashboardShell tablica putnih naloga * WorkOrderDetailModal * TaskCreateModal * Sve PDF stranice - displayIds.js utility za formatiranjeDisplay ID-eva - Admin interface prikazuje display_code
2030 lines
80 KiB
Python
2030 lines
80 KiB
Python
# /backend/modules/fleet/views.py
|
|
|
|
from io import BytesIO
|
|
import base64
|
|
from datetime import timedelta
|
|
|
|
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'
|
|
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} {_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"],
|
|
[place_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 sliku</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."})
|
|
|
|
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']) |