Files
004.erp.generic/backend/modules/fleet/views.py
mariomitte f37d130bc7
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
Reapply "predzadnji unstagaeni dijelovi"
This reverts commit fe4ba3b5dd.
2026-08-07 10:20:32 +02:00

4218 lines
167 KiB
Python

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