Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.
Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
any format)
Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
4523 lines
181 KiB
Python
4523 lines
181 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 datetime, timedelta
|
|
from pathlib import Path
|
|
from decimal import Decimal, InvalidOperation
|
|
from django.contrib.auth import get_user_model
|
|
|
|
from PIL import Image, UnidentifiedImageError
|
|
from django.conf import settings
|
|
from django.core.mail import EmailMessage
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from django.core.validators import validate_email
|
|
from django.http import HttpResponse, FileResponse
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.html import escape
|
|
from django.db.models import Q, Max
|
|
from django.db import transaction
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.lib.styles import getSampleStyleSheet
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.platypus import Table, TableStyle, Paragraph
|
|
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
|
from .email_utils import append_user_signature
|
|
from rest_framework import viewsets, permissions, status, mixins
|
|
from rest_framework.decorators import action, api_view, permission_classes
|
|
from rest_framework.response import Response
|
|
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
|
|
from rest_framework.parsers import MultiPartParser, FormParser
|
|
from celery.exceptions import TimeoutError as CeleryTimeoutError
|
|
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
from .models import (
|
|
Crane,
|
|
Vehicle,
|
|
VehicleNotification,
|
|
EmailDispatchLog,
|
|
ServiceContextNote,
|
|
MonthlyServicerDayEntry,
|
|
WorkOrder,
|
|
WorkOrderPhoto,
|
|
GeneratedWorkOrderPdf,
|
|
GeneratedFleetArchive,
|
|
WorkOrderInvoice,
|
|
WorkOrderAdditionalCostsTable,
|
|
WorkOrderTravelExpensesTable,
|
|
VehicleServiceRecord,
|
|
VehicleServicePhoto,
|
|
VehicleServiceAttachment,
|
|
)
|
|
from .serializers import (
|
|
CraneSerializer,
|
|
VehicleSerializer,
|
|
WorkOrderSerializer,
|
|
WorkOrderInvoiceSerializer,
|
|
WorkOrderAdditionalCostsTableSerializer,
|
|
WorkOrderTravelExpensesTableSerializer,
|
|
WorkOrderPhotoSerializer,
|
|
VehicleServiceRecordSerializer,
|
|
VehicleNotificationSerializer,
|
|
ServiceContextNoteSerializer,
|
|
ServiceContextNoteCreateSerializer,
|
|
MonthlyServicerDayEntrySerializer,
|
|
VehicleServicePhotoSerializer,
|
|
VehicleServiceAttachmentSerializer,
|
|
)
|
|
from infrastructure.pusher_service import PusherService
|
|
from .services import (
|
|
VehicleService,
|
|
VehicleServicePhotoService,
|
|
NotificationService,
|
|
)
|
|
from .tasks import (
|
|
build_work_order_invoices_pdf_task,
|
|
build_work_order_pdf_cached_task,
|
|
build_monthly_archive_cached_task,
|
|
cleanup_expired_generated_archives_task,
|
|
cleanup_expired_generated_pdfs_task,
|
|
process_work_order_invoice_ocr,
|
|
send_work_order_email_bundle_task,
|
|
)
|
|
|
|
register_unicode_fonts()
|
|
User = get_user_model()
|
|
DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates'
|
|
SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx'
|
|
|
|
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
|
|
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
|
|
if asset_type:
|
|
queryset = queryset.filter(asset_type=asset_type)
|
|
if user.is_staff or getattr(user, 'is_serviser', False):
|
|
return queryset
|
|
return queryset
|
|
|
|
def _service_records_queryset_for_user(user):
|
|
queryset = (
|
|
VehicleServiceRecord.objects
|
|
.select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle')
|
|
.prefetch_related('photos', 'attachments')
|
|
.all()
|
|
)
|
|
if user.is_staff:
|
|
return queryset
|
|
return queryset.filter(performed_by=user)
|
|
|
|
|
|
def _work_orders_queryset_for_user(user):
|
|
queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all()
|
|
if user.is_staff:
|
|
return queryset
|
|
if getattr(user, 'is_serviser', False):
|
|
return queryset.filter(
|
|
Q(creator=user) | Q(vehicle__assigned_servicer=user)
|
|
).distinct()
|
|
return queryset.filter(creator=user)
|
|
|
|
|
|
def _is_supervisor_user(user):
|
|
return bool(getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False)))
|
|
|
|
|
|
def _active_team_members_queryset():
|
|
return User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
|
|
|
|
|
|
def _service_context_notes_queryset_for_user(user):
|
|
return (
|
|
ServiceContextNote.objects
|
|
.select_related('created_by', 'recipient', 'work_order', 'task')
|
|
.filter(is_active=True, recipient=user, is_closed=False)
|
|
.order_by('note_date', '-created_at')
|
|
)
|
|
|
|
|
|
def _work_order_related_tasks_queryset(work_order):
|
|
from modules.task_management.models import Task
|
|
|
|
direct_task_ids = list(
|
|
work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
|
|
)
|
|
inferred_task_ids = list(
|
|
VehicleServiceRecord.objects.filter(
|
|
is_active=True,
|
|
vehicle_id=work_order.vehicle_id,
|
|
task_id__isnull=False,
|
|
task__is_active=True,
|
|
task__work_order=work_order,
|
|
).values_list('task_id', flat=True)
|
|
)
|
|
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
|
if not task_ids:
|
|
return Task.objects.none()
|
|
return Task.objects.filter(id__in=task_ids, is_active=True).select_related(
|
|
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
|
|
).order_by('-created_at')
|
|
|
|
|
|
def _parse_report_date_value(value):
|
|
text = str(value or '').strip()
|
|
if not text:
|
|
return None
|
|
for fmt in ('%Y-%m-%d', '%d.%m.%Y.', '%d.%m.%Y'):
|
|
try:
|
|
return datetime.strptime(text, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_report_time_value(value):
|
|
text = str(value or '').strip()
|
|
if not text:
|
|
return None
|
|
for fmt in ('%H:%M', '%H:%M:%S'):
|
|
try:
|
|
return datetime.strptime(text, fmt).time()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_report_decimal_value(value):
|
|
if value in (None, ''):
|
|
return Decimal('0.00')
|
|
try:
|
|
return Decimal(str(value).strip().replace(',', '.'))
|
|
except (InvalidOperation, TypeError, ValueError):
|
|
return Decimal('0.00')
|
|
|
|
|
|
def _calculate_daily_quantity_from_hours(total_hours):
|
|
numeric = Decimal(str(total_hours)) if total_hours not in (None, '') else Decimal('0.00')
|
|
if numeric <= 0:
|
|
return 0.0
|
|
if numeric < Decimal('8'):
|
|
return 0.0
|
|
if numeric < Decimal('12'):
|
|
return 0.5
|
|
return 1.0
|
|
|
|
|
|
def _task_work_hours_entries(task):
|
|
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
|
rows = table_data.get('rows', []) if isinstance(table_data, dict) else []
|
|
if not isinstance(rows, list):
|
|
rows = []
|
|
|
|
entries = []
|
|
work_order = getattr(task, 'work_order', None)
|
|
fallback_date = getattr(task, 'scheduled_date', None)
|
|
task_vehicle = getattr(task, 'vehicle', None)
|
|
task_client = getattr(task_vehicle, 'client', None)
|
|
|
|
for raw_row in rows:
|
|
if not isinstance(raw_row, dict):
|
|
continue
|
|
row_date = _parse_report_date_value(raw_row.get('date')) or fallback_date
|
|
if row_date is None:
|
|
continue
|
|
|
|
start_text = str(raw_row.get('work_time_from') or raw_row.get('travel_time_from') or '').strip()
|
|
end_text = str(raw_row.get('work_time_to') or raw_row.get('travel_time_to') or '').strip()
|
|
start_time = _parse_report_time_value(start_text)
|
|
end_time = _parse_report_time_value(end_text)
|
|
start_dt = datetime.combine(row_date, start_time) if start_time else None
|
|
end_dt = datetime.combine(row_date, end_time) if end_time else None
|
|
if start_dt and end_dt and end_dt <= start_dt:
|
|
end_dt += timedelta(days=1)
|
|
|
|
work_hours = _parse_report_decimal_value(raw_row.get('work_hours'))
|
|
travel_hours = _parse_report_decimal_value(raw_row.get('travel_hours'))
|
|
entries.append({
|
|
'date': row_date,
|
|
'date_label': row_date.strftime('%d.%m.%Y.'),
|
|
'start_dt': start_dt,
|
|
'end_dt': end_dt,
|
|
'work_hours': work_hours,
|
|
'travel_hours': travel_hours,
|
|
'total_hours': work_hours + travel_hours,
|
|
'title': str(getattr(task, 'title', '') or '').strip(),
|
|
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
|
|
'client': str(getattr(task_client, 'name', '') or '').strip(),
|
|
'location': str(getattr(work_order, 'location', '') or '').strip(),
|
|
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
|
|
})
|
|
|
|
if entries:
|
|
return entries
|
|
|
|
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at:
|
|
start_dt = timezone.localtime(work_order.travel_start_at)
|
|
end_dt = timezone.localtime(work_order.travel_end_at)
|
|
fallback_date = fallback_date or start_dt.date()
|
|
travel_hours = Decimal(str((end_dt - start_dt).total_seconds() / 3600.0)).quantize(Decimal('0.01'))
|
|
return [{
|
|
'date': fallback_date,
|
|
'date_label': fallback_date.strftime('%d.%m.%Y.') if fallback_date else '-',
|
|
'start_dt': start_dt,
|
|
'end_dt': end_dt,
|
|
'work_hours': Decimal('0.00'),
|
|
'travel_hours': travel_hours,
|
|
'total_hours': travel_hours,
|
|
'title': str(getattr(task, 'title', '') or '').strip(),
|
|
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
|
|
'client': str(getattr(task_client, 'name', '') or '').strip(),
|
|
'location': str(getattr(work_order, 'location', '') or '').strip(),
|
|
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
|
|
}]
|
|
|
|
return []
|
|
return []
|
|
|
|
def _format_decimal_display(value, *, default='0'):
|
|
if value in (None, ''):
|
|
return default
|
|
try:
|
|
normalized = Decimal(str(value))
|
|
except (InvalidOperation, TypeError, ValueError):
|
|
return str(value)
|
|
text = format(normalized.normalize(), 'f')
|
|
if '.' in text:
|
|
text = text.rstrip('0').rstrip('.')
|
|
return text.replace('.', ',') or default
|
|
|
|
|
|
def _format_decimal_fixed(value, *, default='0.00', places=2):
|
|
if value in (None, ''):
|
|
return default
|
|
try:
|
|
normalized = Decimal(str(value))
|
|
except (InvalidOperation, TypeError, ValueError):
|
|
return str(value)
|
|
quantizer = Decimal('1').scaleb(-places)
|
|
return format(normalized.quantize(quantizer), 'f')
|
|
|
|
|
|
def _normalize_datetime_for_compare(value):
|
|
if not isinstance(value, datetime):
|
|
return value
|
|
if timezone.is_naive(value):
|
|
return timezone.make_aware(value, timezone.get_current_timezone())
|
|
return timezone.localtime(value)
|
|
|
|
|
|
def _work_order_travel_expenses_context(work_order):
|
|
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
|
trip_entries = []
|
|
for task in related_tasks:
|
|
trip_entries.extend(_task_work_hours_entries(task))
|
|
|
|
travel_start = getattr(work_order, 'travel_start_at', None)
|
|
travel_end = getattr(work_order, 'travel_end_at', None)
|
|
trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None)
|
|
trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None)
|
|
total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00'))
|
|
|
|
if trip_entries:
|
|
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
|
start_candidates = [
|
|
_normalize_datetime_for_compare(entry.get('start_dt'))
|
|
for entry in trip_entries
|
|
if entry.get('start_dt')
|
|
]
|
|
end_candidates = [
|
|
_normalize_datetime_for_compare(entry.get('end_dt'))
|
|
for entry in trip_entries
|
|
if entry.get('end_dt')
|
|
]
|
|
if entry_dates:
|
|
trip_start_date = min(entry_dates)
|
|
trip_end_date = max(entry_dates)
|
|
if start_candidates:
|
|
travel_start = min(start_candidates)
|
|
if end_candidates:
|
|
travel_end = max(end_candidates)
|
|
|
|
table = getattr(work_order, 'travel_expenses_table', None)
|
|
if table:
|
|
broj_sati = Decimal(str(table.broj_sati or '0'))
|
|
kolicina_dnevnica = Decimal(str(table.kolicina_dnevnica or '0'))
|
|
iznos_dnevnica = Decimal(str(table.iznos_dnevnica or '0'))
|
|
daily_rate_country = str(table.daily_rate_country or WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY)
|
|
total_for_payout = Decimal(str(table.total_for_payout or '0')).quantize(Decimal('0.01'))
|
|
else:
|
|
broj_sati = total_hours.quantize(Decimal('0.01'))
|
|
kolicina_dnevnica = Decimal(str(_calculate_daily_quantity_from_hours(broj_sati)))
|
|
daily_rate_country = WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
|
|
iznos_dnevnica = WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT
|
|
total_for_payout = (kolicina_dnevnica * iznos_dnevnica).quantize(Decimal('0.01'))
|
|
|
|
return {
|
|
'related_tasks': related_tasks,
|
|
'trip_entries': trip_entries,
|
|
'travel_start': travel_start,
|
|
'travel_end': travel_end,
|
|
'trip_start_date': trip_start_date,
|
|
'trip_end_date': trip_end_date,
|
|
'broj_sati': broj_sati,
|
|
'kolicina_dnevnica': kolicina_dnevnica,
|
|
'iznos_dnevnica': iznos_dnevnica,
|
|
'daily_rate_country': daily_rate_country,
|
|
'total_for_payout': total_for_payout,
|
|
}
|
|
|
|
|
|
def _can_access_service_record(user, service_record):
|
|
service_record_id = getattr(service_record, 'pk', service_record)
|
|
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
|
|
|
|
def _parse_positive_int(value, *, field_name, default, min_value, max_value):
|
|
if value in (None, ''):
|
|
return default
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."})
|
|
if parsed < min_value or parsed > max_value:
|
|
raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."})
|
|
return parsed
|
|
|
|
def _parse_format(value):
|
|
selected = (value or 'webp').lower()
|
|
supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'}
|
|
if selected not in supported:
|
|
raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."})
|
|
extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
|
|
return supported[selected], extension
|
|
|
|
|
|
GENERATED_PDF_TTL_HOURS = 24
|
|
GENERATED_ARCHIVE_TTL_DAYS = 7
|
|
|
|
|
|
def _work_order_display_code(work_order):
|
|
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
|
|
if normalized:
|
|
return normalized
|
|
return 'NALOG'
|
|
|
|
|
|
def _sanitize_task_title_for_filename(value):
|
|
normalized = re.sub(r'\s+', '_', str(value or '').strip())
|
|
normalized = re.sub(r'[^A-Za-z0-9_-]+', '', normalized)
|
|
normalized = normalized.strip('._-')
|
|
return normalized or 'servisni_zapis'
|
|
|
|
|
|
def _service_task_filename_label(task):
|
|
return f"SN-{_sanitize_task_title_for_filename(getattr(task, 'title', ''))}"
|
|
|
|
|
|
def _pdf_filename(work_order, pdf_type):
|
|
display_code = _work_order_display_code(work_order)
|
|
if pdf_type == 'invoices':
|
|
return f"{display_code}.work-order-invoices.pdf"
|
|
if pdf_type == 'service_records':
|
|
return f"{display_code}.work-order-service-records.pdf"
|
|
return f"{display_code}.work-order.pdf"
|
|
|
|
|
|
def _docx_filename(work_order, doc_type):
|
|
display_code = _work_order_display_code(work_order)
|
|
if doc_type == 'invoices':
|
|
return f"{display_code}.work-order-invoices.docx"
|
|
if doc_type == 'service_records':
|
|
return f"{display_code}.work-order-service-records.docx"
|
|
return f"{display_code}.work-order.docx"
|
|
|
|
|
|
def _service_records_pdf_filename(work_order, task=None):
|
|
if not task:
|
|
return _pdf_filename(work_order, 'service_records')
|
|
display_code = _work_order_display_code(work_order)
|
|
return f"{display_code}.{_service_task_filename_label(task)}.pdf"
|
|
|
|
|
|
def _service_records_docx_filename(work_order, task=None):
|
|
if not task:
|
|
return _docx_filename(work_order, 'service_records')
|
|
display_code = _work_order_display_code(work_order)
|
|
return f"{display_code}.{_service_task_filename_label(task)}.docx"
|
|
|
|
|
|
def _resolve_service_report_tasks(work_order, task_id):
|
|
queryset = _work_order_related_tasks_queryset(work_order)
|
|
if not task_id:
|
|
return list(queryset), None
|
|
selected_task = queryset.filter(pk=task_id).first()
|
|
if not selected_task:
|
|
raise DRFValidationError({
|
|
'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.'
|
|
})
|
|
return [selected_task], selected_task
|
|
|
|
|
|
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 20 # preskači slike iznad 20 MP kako bi se izbjegao OOM
|
|
|
|
|
|
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.
|
|
|
|
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
|
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
|
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
|
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
|
"""
|
|
try:
|
|
image_field.open('rb')
|
|
with Image.open(image_field) as img:
|
|
w, h = img.size # čita samo header, bez dekodiranja piksela
|
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
|
return None
|
|
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
|
rgb = img.convert('RGB')
|
|
buf = BytesIO()
|
|
rgb.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.
|
|
|
|
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
|
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
|
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
|
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
|
"""
|
|
try:
|
|
image_field.open('rb')
|
|
with Image.open(image_field) as img:
|
|
w, h = img.size # čita samo header, bez dekodiranja piksela
|
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
|
return None
|
|
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
|
rgb = img.convert('RGB')
|
|
buf = BytesIO()
|
|
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
|
|
buf.seek(0)
|
|
return buf
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
try:
|
|
image_field.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _get_cached_pdf(work_order, pdf_type):
|
|
now = timezone.now()
|
|
expected_filename = _pdf_filename(work_order, pdf_type)
|
|
candidates = list(
|
|
GeneratedWorkOrderPdf.objects
|
|
.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
pdf_type=pdf_type,
|
|
status='ready',
|
|
expires_at__gt=now,
|
|
)
|
|
.exclude(file='')
|
|
.exclude(file__isnull=True)
|
|
.order_by('-created_at')
|
|
)
|
|
for candidate in candidates:
|
|
if str(candidate.filename or '').strip() == expected_filename:
|
|
file_name = getattr(candidate.file, 'name', '')
|
|
storage = getattr(candidate.file, 'storage', None)
|
|
if file_name and storage:
|
|
try:
|
|
if storage.exists(file_name):
|
|
return candidate
|
|
except (FileNotFoundError, OSError, ValueError):
|
|
pass
|
|
candidate.file = None
|
|
candidate.is_active = False
|
|
candidate.status = 'failed'
|
|
candidate.error_message = 'PDF datoteka nije dostupna.'
|
|
candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
return None
|
|
|
|
|
|
def _mark_generated_pdf_failed(generated_pdf, error_message):
|
|
generated_pdf.file = None
|
|
generated_pdf.is_active = False
|
|
generated_pdf.status = 'failed'
|
|
generated_pdf.error_message = error_message
|
|
generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _cleanup_expired_generated_pdfs():
|
|
now = timezone.now()
|
|
expired = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
expires_at__isnull=False,
|
|
expires_at__lte=now,
|
|
)
|
|
for item in expired:
|
|
if item.file:
|
|
item.file.delete(save=False)
|
|
item.file = None
|
|
item.is_active = False
|
|
item.status = 'failed'
|
|
item.error_message = 'PDF cache istekao.'
|
|
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _parse_amount_decimal(value):
|
|
if value in (None, ''):
|
|
return Decimal('0.00')
|
|
try:
|
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
|
return Decimal(normalized)
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return Decimal('0.00')
|
|
|
|
|
|
def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
|
cache_qs = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
)
|
|
if pdf_types:
|
|
cache_qs = cache_qs.filter(pdf_type__in=pdf_types)
|
|
for cached in cache_qs:
|
|
if cached.file:
|
|
cached.file.delete(save=False)
|
|
cached.file = None
|
|
cached.is_active = False
|
|
cached.status = 'failed'
|
|
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
|
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _upsert_additional_cost_row_from_invoice(invoice):
|
|
if invoice is None or invoice.work_order_id is None:
|
|
return
|
|
table, _ = WorkOrderAdditionalCostsTable.objects.get_or_create(
|
|
work_order=invoice.work_order,
|
|
defaults={'data': {'rows': []}, 'total_for_payout': Decimal('0.00')},
|
|
)
|
|
rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
|
normalized_rows = [
|
|
row for row in rows
|
|
if isinstance(row, dict) and str(row.get('source_invoice_id', '')) != str(invoice.pk)
|
|
]
|
|
normalized_rows.append({
|
|
'naziv': str(invoice.naziv_racuna or '').strip() or f'Račun {invoice.pk}',
|
|
'broj_racuna': str(invoice.pk),
|
|
'ukupan_iznos': '0.00',
|
|
'prilog': Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '',
|
|
'source_invoice_id': str(invoice.pk),
|
|
})
|
|
total = sum(
|
|
(_parse_amount_decimal(row.get('ukupan_iznos')) for row in normalized_rows if isinstance(row, dict)),
|
|
Decimal('0.00'),
|
|
).quantize(Decimal('0.01'))
|
|
table.data = {'rows': normalized_rows}
|
|
table.total_for_payout = total
|
|
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
|
|
|
|
|
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
|
try:
|
|
generated_pdf.file.open('rb')
|
|
except (FileNotFoundError, OSError, ValueError):
|
|
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
filename = generated_pdf.filename or default_filename
|
|
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
response['Cache-Control'] = 'private, max-age=3600'
|
|
return response
|
|
|
|
|
|
def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None):
|
|
if user is None:
|
|
return
|
|
if doc_type == 'work_order':
|
|
doc_label = "putnog naloga"
|
|
elif doc_type == 'service_records':
|
|
doc_label = "servisnih zapisa putnog naloga"
|
|
else:
|
|
doc_label = "računa putnog naloga"
|
|
|
|
display_code = _work_order_display_code(work_order)
|
|
if stage == 'requested':
|
|
title = f"Zahtjev za PDF {doc_label}"
|
|
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za putni nalog {display_code}."
|
|
level = "info"
|
|
elif stage == 'failed':
|
|
title = f"Greška kod PDF-a ({doc_label})"
|
|
message = f"Generiranje PDF dokumenta nije uspjelo za putni nalog {display_code}."
|
|
level = "warning"
|
|
else:
|
|
title = f"PDF spreman ({doc_label})"
|
|
message = f"PDF dokument je uspješno generiran za putni nalog {display_code}."
|
|
level = "success"
|
|
|
|
metadata = {
|
|
"entity_type": "work_order_pdf",
|
|
"work_order_id": str(work_order.pk),
|
|
"vehicle_id": str(work_order.vehicle_id),
|
|
"section": "work-orders",
|
|
"pdf_type": doc_type,
|
|
"stage": stage,
|
|
}
|
|
if generated_pdf and generated_pdf.pk:
|
|
metadata["generated_pdf_id"] = str(generated_pdf.pk)
|
|
metadata["download_url"] = (
|
|
f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/"
|
|
)
|
|
metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type)
|
|
if generated_pdf.expires_at:
|
|
metadata["expires_at"] = generated_pdf.expires_at.isoformat()
|
|
|
|
NotificationService.create_notification(
|
|
recipient=user,
|
|
title=title,
|
|
message=message,
|
|
level=level,
|
|
send_email=False,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _request_cached_pdf_generation(*, request, work_order, pdf_type):
|
|
_cleanup_expired_generated_pdfs()
|
|
cached = _get_cached_pdf(work_order, pdf_type)
|
|
if cached:
|
|
_notify_pdf_request(
|
|
user=request.user,
|
|
work_order=work_order,
|
|
doc_type=pdf_type,
|
|
stage='completed',
|
|
generated_pdf=cached,
|
|
)
|
|
return {
|
|
"status": "ready",
|
|
"generated_pdf_id": str(cached.pk),
|
|
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/",
|
|
"filename": cached.filename or _pdf_filename(work_order, pdf_type),
|
|
"expires_at": cached.expires_at.isoformat() if cached.expires_at else None,
|
|
}
|
|
|
|
existing_pending = GeneratedWorkOrderPdf.objects.filter(
|
|
is_active=True,
|
|
work_order=work_order,
|
|
pdf_type=pdf_type,
|
|
status='pending',
|
|
).order_by('-created_at').first()
|
|
|
|
if existing_pending:
|
|
return {
|
|
"status": "processing",
|
|
"generated_pdf_id": str(existing_pending.pk),
|
|
}
|
|
|
|
generated_pdf = GeneratedWorkOrderPdf.objects.create(
|
|
work_order=work_order,
|
|
requested_by=request.user,
|
|
pdf_type=pdf_type,
|
|
status='pending',
|
|
filename=_pdf_filename(work_order, pdf_type),
|
|
expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS),
|
|
)
|
|
_notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested')
|
|
|
|
try:
|
|
build_work_order_pdf_cached_task.delay(str(generated_pdf.pk))
|
|
cleanup_expired_generated_pdfs_task.delay()
|
|
except KombuOperationalError:
|
|
build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get()
|
|
cleanup_expired_generated_pdfs_task.apply().get()
|
|
|
|
return {
|
|
"status": "processing",
|
|
"generated_pdf_id": str(generated_pdf.pk),
|
|
}
|
|
|
|
def _build_simple_pdf(title, rows):
|
|
buffer = BytesIO()
|
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
|
width, height = A4
|
|
y = height - 50
|
|
|
|
pdf.setFont("Vera-Bold", 14)
|
|
pdf.drawString(40, y, title)
|
|
y -= 24
|
|
|
|
pdf.setFont("Vera", 10)
|
|
for row in rows:
|
|
text = f"{row[0]}: {row[1]}"
|
|
if y < 60:
|
|
pdf.showPage()
|
|
pdf.setFont("Vera", 10)
|
|
y = height - 50
|
|
pdf.drawString(40, y, text[:140])
|
|
y -= 16
|
|
|
|
pdf.save()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _draw_pdf_row(pdf, *, label, value, y, width, height):
|
|
if y < 60:
|
|
pdf.showPage()
|
|
pdf.setFont("Vera", 10)
|
|
y = height - 50
|
|
text = f"{label}: {value}"
|
|
pdf.drawString(40, y, text[:180])
|
|
return y - 16
|
|
|
|
|
|
def _build_work_order_pdf(work_order):
|
|
vehicle = work_order.vehicle
|
|
creator = work_order.creator
|
|
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
|
|
now_local = timezone.localtime(timezone.now())
|
|
tz = timezone.get_current_timezone()
|
|
display_code = _work_order_display_code(work_order)
|
|
|
|
def _fmt_date(value):
|
|
if not value:
|
|
return '-'
|
|
if hasattr(value, 'strftime'):
|
|
return value.strftime('%d.%m.%Y.')
|
|
return str(value)
|
|
|
|
def _fmt_time(value):
|
|
if not value:
|
|
return '-'
|
|
return value.astimezone(tz).strftime('%H:%M')
|
|
|
|
def _fmt_eur(value):
|
|
try:
|
|
return f"{float(value):.2f} €"
|
|
except (TypeError, ValueError):
|
|
return "0,00 €"
|
|
|
|
def _parse_decimal(value):
|
|
if value in (None, ''):
|
|
return Decimal('0.00')
|
|
try:
|
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
|
return Decimal(normalized)
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return Decimal('0.00')
|
|
|
|
def _paragraph(text, *, bold=False, size=9, align=0):
|
|
styles = getSampleStyleSheet()
|
|
base = styles['BodyText'].clone('wo-p')
|
|
base.fontName = 'Vera-Bold' if bold else 'Vera'
|
|
base.fontSize = size
|
|
base.leading = size + 1.5
|
|
base.alignment = align
|
|
return Paragraph(text, base)
|
|
|
|
def _hours_between(start_at, end_at):
|
|
if not start_at or not end_at:
|
|
return 0.0
|
|
delta = end_at - start_at
|
|
return max(0.0, round(delta.total_seconds() / 3600.0, 2))
|
|
|
|
company_name = getattr(getattr(vehicle, 'client', None), 'name', None) or "KNEZ LJUBO d.o.o."
|
|
creator_name = _user_display_name(creator) or creator.email
|
|
creator_occupation = (getattr(creator, 'occupation', None) or '').strip() or (
|
|
"Servisni inženjer" if getattr(creator, 'is_serviser', False) else "Djelatnik"
|
|
)
|
|
creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
|
|
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
|
|
travel_context = _work_order_travel_expenses_context(work_order)
|
|
related_tasks = travel_context['related_tasks']
|
|
trip_entries = travel_context['trip_entries']
|
|
travel_start = travel_context['travel_start']
|
|
travel_end = travel_context['travel_end']
|
|
trip_start_date = travel_context['trip_start_date']
|
|
trip_end_date = travel_context['trip_end_date']
|
|
travel_hours = travel_context['broj_sati']
|
|
daily_qty = travel_context['kolicina_dnevnica']
|
|
daily_rate = travel_context['iznos_dnevnica']
|
|
daily_total = travel_context['total_for_payout']
|
|
transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost)
|
|
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
|
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
|
additional_table = getattr(work_order, 'additional_costs_table', None)
|
|
additional_rows_data = []
|
|
additional_total_decimal = Decimal('0.00')
|
|
if additional_table and isinstance(additional_table.data, dict):
|
|
additional_rows_data = additional_table.data.get('rows', []) if isinstance(additional_table.data.get('rows', []), list) else []
|
|
additional_total_decimal = _parse_decimal(additional_table.total_for_payout)
|
|
grand_total = daily_total + transport_total + additional_total_decimal
|
|
|
|
attachment_names = [
|
|
str(row.get('prilog', '')).strip()
|
|
for row in additional_rows_data
|
|
if isinstance(row, dict) and str(row.get('prilog', '')).strip()
|
|
]
|
|
if not attachment_names:
|
|
attachment_names = [Path(inv.image.name).name for inv in invoices if inv.image and getattr(inv.image, 'name', '')]
|
|
attachments_text = ', '.join(attachment_names[:8]) if attachment_names else '-'
|
|
assigned_servicer_vehicle = (
|
|
creator.assigned_vehicles
|
|
.filter(asset_type='vehicle', is_active=True)
|
|
.order_by('registration_number')
|
|
.first()
|
|
)
|
|
assigned_servicer_vehicle_label = "-"
|
|
assigned_servicer_vehicle_registration = "-"
|
|
if assigned_servicer_vehicle:
|
|
assigned_servicer_vehicle_label = " ".join(
|
|
part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part
|
|
).strip() or assigned_servicer_vehicle.registration_number
|
|
assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-"
|
|
|
|
servicer_vehicle_label = (
|
|
assigned_servicer_vehicle_label
|
|
if assigned_servicer_vehicle
|
|
else (work_order.servicer_vehicle_make_model or '-')
|
|
)
|
|
servicer_vehicle_registration = (
|
|
assigned_servicer_vehicle_registration
|
|
if assigned_servicer_vehicle
|
|
else (work_order.servicer_vehicle_registration or '-')
|
|
)
|
|
|
|
buffer = BytesIO()
|
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
|
page_w, page_h = A4
|
|
margin = 16
|
|
content_w = page_w - (2 * margin)
|
|
y = page_h - 18
|
|
|
|
def draw_table(y_pos, data, widths, styles, row_heights=None):
|
|
table = Table(data, colWidths=widths, rowHeights=row_heights)
|
|
table.setStyle(TableStyle(styles))
|
|
_, table_h = table.wrap(content_w, page_h)
|
|
if y_pos - table_h < 28:
|
|
pdf.showPage()
|
|
y_pos = page_h - 18
|
|
table.drawOn(pdf, margin, y_pos - table_h)
|
|
return y_pos - table_h - 4
|
|
|
|
# 1) Zaglavlje + određujem da
|
|
section_1_top = [[
|
|
_paragraph(f"Trgovačko društvo<br/><b>{company_name}</b>", size=8.8),
|
|
_paragraph("N A L O G<br/>ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
|
|
_paragraph(
|
|
f"Mjesto i datum:<br/><b>{place_label} {_fmt_date(now_local.date())}</b><br/>"
|
|
f"Broj naloga:<br/><b>{display_code}</b>",
|
|
size=8.8,
|
|
),
|
|
]]
|
|
y = draw_table(
|
|
y,
|
|
section_1_top,
|
|
[content_w * 0.305, content_w * 0.35, content_w * 0.345],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 3),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
|
|
],
|
|
row_heights=[44],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[[_paragraph("Određujem da:", size=11)]],
|
|
[content_w],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[14],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[[
|
|
_paragraph(f"Ime i prezime:<br/><b>{creator_name}</b><br/><br/>Prebivalište:<br/><b>{creator_residence}</b>", size=8.7),
|
|
_paragraph(f"Zanimanje:<br/><b>{creator_occupation}</b><br/><br/>Na radnom mjestu:<br/><b>{creator_work_position}</b>", size=8.7),
|
|
]],
|
|
[content_w * 0.66, content_w * 0.34],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[58],
|
|
)
|
|
|
|
# 2) Putuje dana / mjesto / zadatak
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["Službeno otputuje dana:", "U mjesto:", "Sa zadaćom:"],
|
|
[
|
|
"1 dana" if travel_start else "-",
|
|
work_order.location or '-',
|
|
work_order.purpose or '-',
|
|
],
|
|
],
|
|
[content_w / 3, content_w / 3, content_w / 3],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.6),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
],
|
|
row_heights=[14, 19],
|
|
)
|
|
|
|
# 3) Trajanje + kilometraže
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["Putovanje može trajati:", "Datum polaska:", "Odobravamo upotrebu vozila:"],
|
|
[
|
|
"1 dana" if travel_start else "-",
|
|
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
|
f"{servicer_vehicle_label}, {servicer_vehicle_registration}",
|
|
],
|
|
["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
|
|
[
|
|
company_name,
|
|
"-",
|
|
str(work_order.servicer_vehicle_start_mileage or work_order.start_mileage or '-'),
|
|
],
|
|
["", "", "Završno stanje kilometara:"],
|
|
["", "", str(work_order.servicer_vehicle_end_mileage or work_order.end_mileage or '-')],
|
|
],
|
|
[content_w / 3, content_w / 3, content_w / 3],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTNAME', (0, 3), (2, 3), 'Vera-Bold'),
|
|
('FONTNAME', (2, 5), (2, 5), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
],
|
|
row_heights=[14, 18, 14, 18, 12, 14],
|
|
)
|
|
|
|
# 4) Obračun putnih troškova
|
|
section4_top = [
|
|
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
|
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
|
|
[
|
|
_fmt_date(trip_start_date or work_order.date),
|
|
_fmt_time(travel_start),
|
|
_fmt_date(trip_end_date or work_order.date),
|
|
_fmt_time(travel_end),
|
|
_format_decimal_display(travel_hours),
|
|
_format_decimal_display(daily_qty),
|
|
_fmt_eur(daily_rate),
|
|
_fmt_eur(daily_total),
|
|
],
|
|
]
|
|
for _ in range(3):
|
|
section4_top.append(["", "", "", "", "", "", "", ""])
|
|
y = draw_table(
|
|
y,
|
|
section4_top,
|
|
[content_w * 0.12, content_w * 0.09, content_w * 0.12, content_w * 0.09, content_w * 0.11, content_w * 0.13, content_w * 0.17, content_w * 0.17],
|
|
[
|
|
('SPAN', (0, 0), (7, 0)),
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (7, 0), 'Vera-Bold'),
|
|
('FONTNAME', (0, 1), (7, 1), 'Vera'),
|
|
('FONTNAME', (0, 2), (7, 2), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('ALIGN', (0, 0), (7, 0), 'CENTER'),
|
|
('ALIGN', (0, 1), (7, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 2),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[15, 14, 14, 12, 12, 12],
|
|
)
|
|
y = draw_table(
|
|
y,
|
|
[
|
|
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
|
|
[origin_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
|
["", "", "", "", "", ""],
|
|
["", "", "", "", "", ""],
|
|
],
|
|
[content_w * 0.18, content_w * 0.18, content_w * 0.20, content_w * 0.10, content_w * 0.17, content_w * 0.17],
|
|
[
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Vera'),
|
|
('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 2),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 2),
|
|
],
|
|
row_heights=[14, 14, 12, 12],
|
|
)
|
|
|
|
# 5) Dodatni troškovi
|
|
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
|
|
if additional_rows_data:
|
|
for row in additional_rows_data[:4]:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
additional_rows.append([
|
|
str(row.get('naziv', '') or '-'),
|
|
str(row.get('broj_racuna', '') or '-'),
|
|
_fmt_eur(_parse_decimal(row.get('ukupan_iznos'))),
|
|
])
|
|
else:
|
|
for inv in invoices[:4]:
|
|
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
|
|
additional_total_row_idx = len(additional_rows)
|
|
additional_rows.append(["UKUPNO", "", _fmt_eur(additional_total_decimal)])
|
|
while len(additional_rows) < 7:
|
|
additional_rows.append(["", "", ""])
|
|
y = draw_table(
|
|
y,
|
|
additional_rows,
|
|
[content_w * 0.53, content_w * 0.30, content_w * 0.17],
|
|
[
|
|
('SPAN', (0, 0), (2, 0)),
|
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
|
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
|
|
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
|
|
('FONTNAME', (0, additional_total_row_idx), (2, additional_total_row_idx), 'Vera-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8.4),
|
|
('ALIGN', (0, 0), (2, 0), 'CENTER'),
|
|
('ALIGN', (2, 1), (2, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
|
],
|
|
row_heights=[14, 14, 12, 12, 12, 12, 12],
|
|
)
|
|
|
|
# 6) Prilozi + ukupna isplata
|
|
y = draw_table(
|
|
y,
|
|
[[
|
|
_paragraph(f"Prilozi:<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',
|
|
'work_hours_table',
|
|
)
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
manual_entries = MonthlyServicerDayEntry.objects.filter(
|
|
user=user,
|
|
is_active=True,
|
|
entry_date__year=year,
|
|
entry_date__month=month,
|
|
).order_by('entry_date')
|
|
|
|
entries_by_date = OrderedDict()
|
|
for task in tasks_qs:
|
|
for entry in _task_work_hours_entries(task):
|
|
entries_by_date.setdefault(entry['date'], []).append(entry)
|
|
|
|
manual_by_date = {
|
|
entry.entry_date: entry
|
|
for entry in manual_entries
|
|
}
|
|
|
|
rows = []
|
|
days_in_month = monthrange(year, month)[1]
|
|
for day in range(1, days_in_month + 1):
|
|
current_date = _date(year, month, day)
|
|
day_entries = entries_by_date.get(current_date, [])
|
|
if day_entries:
|
|
titles = OrderedDict()
|
|
serials = OrderedDict()
|
|
clients = OrderedDict()
|
|
locations = OrderedDict()
|
|
work_orders = OrderedDict()
|
|
start_values = []
|
|
end_values = []
|
|
total_hours = Decimal('0.00')
|
|
|
|
for entry in day_entries:
|
|
if entry.get('title'):
|
|
titles[entry['title']] = entry['title']
|
|
if entry.get('serial'):
|
|
serials[entry['serial']] = entry['serial']
|
|
if entry.get('client'):
|
|
clients[entry['client']] = entry['client']
|
|
if entry.get('location'):
|
|
locations[entry['location']] = entry['location']
|
|
if entry.get('work_order_label'):
|
|
work_orders[entry['work_order_label']] = entry['work_order_label']
|
|
if entry.get('start_dt'):
|
|
start_values.append(entry['start_dt'])
|
|
if entry.get('end_dt'):
|
|
end_values.append(entry['end_dt'])
|
|
total_hours += entry.get('total_hours', Decimal('0.00'))
|
|
|
|
start_label = '-'
|
|
end_label = '-'
|
|
if start_values:
|
|
start_label = min(start_values).strftime('%H:%M')
|
|
if end_values:
|
|
end_label = max(end_values).strftime('%H:%M')
|
|
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': ' | '.join(titles.values()) or '-',
|
|
'br_dizalice': ', '.join(serials.values()) or '-',
|
|
'komitent': ', '.join(clients.values()) or '-',
|
|
'mjesto_rada': ', '.join(locations.values()) or '-',
|
|
'pocetak_rada': start_label,
|
|
'kraj_rada': end_label,
|
|
'redovan_rad': '8',
|
|
'prekovremeni': _format_report_hours(total_hours, default='0'),
|
|
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
|
'source': 'task',
|
|
})
|
|
continue
|
|
|
|
manual_entry = manual_by_date.get(current_date)
|
|
if manual_entry:
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': str(manual_entry.description or '').strip() or '-',
|
|
'br_dizalice': '-',
|
|
'komitent': '-',
|
|
'mjesto_rada': str(manual_entry.location or '').strip() or '-',
|
|
'pocetak_rada': manual_entry.start_time.strftime('%H:%M') if manual_entry.start_time else '-',
|
|
'kraj_rada': manual_entry.end_time.strftime('%H:%M') if manual_entry.end_time else '-',
|
|
'redovan_rad': _format_report_hours(manual_entry.regular_hours, default='0'),
|
|
'prekovremeni': _format_report_hours(manual_entry.overtime_hours, default='0'),
|
|
'radni_nalog': '-',
|
|
'source': manual_entry.entry_type,
|
|
})
|
|
continue
|
|
|
|
rows.append({
|
|
'date': current_date,
|
|
'datum': current_date.strftime('%d.%m.%Y'),
|
|
'opis_posla': '-',
|
|
'br_dizalice': '-',
|
|
'komitent': '-',
|
|
'mjesto_rada': '-',
|
|
'pocetak_rada': '-',
|
|
'kraj_rada': '-',
|
|
'redovan_rad': '0',
|
|
'prekovremeni': '0',
|
|
'radni_nalog': '-',
|
|
'source': 'empty',
|
|
})
|
|
|
|
return rows
|
|
|
|
|
|
class MonthlyServicerDayEntryViewSet(viewsets.ModelViewSet):
|
|
serializer_class = MonthlyServicerDayEntrySerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
|
|
|
|
def get_queryset(self):
|
|
queryset = MonthlyServicerDayEntry.objects.filter(
|
|
user=self.request.user,
|
|
is_active=True,
|
|
).order_by('entry_date')
|
|
year = self.request.query_params.get('year')
|
|
month = self.request.query_params.get('month')
|
|
if year and str(year).isdigit():
|
|
queryset = queryset.filter(entry_date__year=int(year))
|
|
if month and str(month).isdigit():
|
|
queryset = queryset.filter(entry_date__month=int(month))
|
|
return queryset
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
raw_entry_date = request.data.get('entry_date')
|
|
existing = None
|
|
if raw_entry_date:
|
|
existing = self.get_queryset().filter(entry_date=raw_entry_date).first()
|
|
|
|
if existing is not None:
|
|
serializer = self.get_serializer(existing, data=request.data, partial=True)
|
|
serializer.is_valid(raise_exception=True)
|
|
entry = serializer.save()
|
|
return Response(self.get_serializer(entry).data, status=status.HTTP_200_OK)
|
|
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
entry = serializer.save(user=request.user)
|
|
return Response(self.get_serializer(entry).data, status=status.HTTP_201_CREATED)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active', 'updated_at'])
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_servicer_report_docx(request):
|
|
"""Download monthly servicer report as DOCX (landscape table)."""
|
|
from datetime import date as _dt_date
|
|
try:
|
|
year = int(request.query_params.get('year', _dt_date.today().year))
|
|
month = int(request.query_params.get('month', _dt_date.today().month))
|
|
if not (1 <= month <= 12):
|
|
raise ValueError()
|
|
except (ValueError, TypeError):
|
|
return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
|
|
|
|
try:
|
|
normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month)
|
|
except ImportError:
|
|
return Response({'detail': 'Task model nije dostupan.'}, status=500)
|
|
|
|
from docx import Document as _DocxDoc
|
|
from docx.shared import Pt, Cm
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.enum.section import WD_ORIENT
|
|
|
|
doc = _DocxDoc()
|
|
for section in doc.sections:
|
|
section.orientation = WD_ORIENT.LANDSCAPE
|
|
section.page_width = Cm(29.7)
|
|
section.page_height = Cm(21.0)
|
|
section.left_margin = Cm(1.5)
|
|
section.right_margin = Cm(1.5)
|
|
section.top_margin = Cm(1.5)
|
|
section.bottom_margin = Cm(1.5)
|
|
|
|
month_label = _MONTH_NAMES_HR[month - 1]
|
|
servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user))
|
|
|
|
p_title = doc.add_paragraph()
|
|
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ SERVISERA')
|
|
r_title.bold = True
|
|
r_title.font.size = Pt(14)
|
|
|
|
p_sub = doc.add_paragraph()
|
|
p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}")
|
|
r_sub.font.size = Pt(11)
|
|
|
|
headers = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA',
|
|
'POČETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG']
|
|
col_widths = [Cm(2.2), Cm(5.5), Cm(2.5), Cm(3.5), Cm(3.5),
|
|
Cm(2.2), Cm(2.2), Cm(2.4), Cm(2.4), Cm(2.4)]
|
|
|
|
table = doc.add_table(rows=1 + len(normalized_rows), cols=len(headers))
|
|
table.style = 'Table Grid'
|
|
|
|
hdr_cells = table.rows[0].cells
|
|
for i, (hdr, w) in enumerate(zip(headers, col_widths)):
|
|
hdr_cells[i].width = w
|
|
hdr_cells[i].text = hdr
|
|
if hdr_cells[i].paragraphs[0].runs:
|
|
run_h = hdr_cells[i].paragraphs[0].runs[0]
|
|
run_h.bold = True
|
|
run_h.font.size = Pt(8)
|
|
|
|
for ri, row_data in enumerate(normalized_rows):
|
|
data_cells = table.rows[ri + 1].cells
|
|
values = [
|
|
row_data['datum'],
|
|
row_data['opis_posla'],
|
|
row_data['br_dizalice'],
|
|
row_data['komitent'],
|
|
row_data['mjesto_rada'],
|
|
row_data['pocetak_rada'],
|
|
row_data['kraj_rada'],
|
|
row_data['redovan_rad'],
|
|
row_data['prekovremeni'],
|
|
row_data['radni_nalog'],
|
|
]
|
|
for ci, (val, w) in enumerate(zip(values, col_widths)):
|
|
data_cells[ci].width = w
|
|
data_cells[ci].text = str(val)
|
|
if data_cells[ci].paragraphs[0].runs:
|
|
data_cells[ci].paragraphs[0].runs[0].font.size = Pt(8)
|
|
|
|
buf = BytesIO()
|
|
doc.save(buf)
|
|
buf.seek(0)
|
|
|
|
month_key = f"{year}-{month:02d}"
|
|
safe_name = re.sub(r'[^\w\-]', '_', servicer_name)
|
|
fname = f"{safe_name}.izvjestaj-servisera.{month_key}.docx"
|
|
resp = HttpResponse(
|
|
buf.read(),
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
resp['Content-Disposition'] = f'attachment; filename="{fname}"'
|
|
return resp
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_costs_report_docx(request):
|
|
"""Download monthly servicer costs (invoices) report as DOCX."""
|
|
from datetime import date as _dt_date
|
|
try:
|
|
year = int(request.query_params.get('year', _dt_date.today().year))
|
|
month = int(request.query_params.get('month', _dt_date.today().month))
|
|
if not (1 <= month <= 12):
|
|
raise ValueError()
|
|
except (ValueError, TypeError):
|
|
return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
|
|
|
|
invoices_qs = (
|
|
WorkOrderInvoice.objects
|
|
.filter(
|
|
work_order__work_order_tasks__assigned_to=request.user,
|
|
work_order__work_order_tasks__is_active=True,
|
|
datum__year=year,
|
|
datum__month=month,
|
|
)
|
|
.select_related('work_order')
|
|
.distinct()
|
|
.order_by('datum', 'naziv_racuna')
|
|
)
|
|
|
|
rows = []
|
|
for inv in invoices_qs:
|
|
wo = inv.work_order
|
|
rows.append([
|
|
inv.datum.strftime('%d.%m.%Y') if inv.datum else '-',
|
|
inv.naziv_racuna or '-',
|
|
inv.lokacija or '-',
|
|
inv.opis or '-',
|
|
str(getattr(wo, 'display_code', '') or '').strip() or '-',
|
|
])
|
|
|
|
from docx import Document as _DocxDoc
|
|
from docx.shared import Pt, Cm
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.enum.section import WD_ORIENT
|
|
|
|
doc = _DocxDoc()
|
|
for section in doc.sections:
|
|
section.orientation = WD_ORIENT.LANDSCAPE
|
|
section.page_width = Cm(29.7)
|
|
section.page_height = Cm(21.0)
|
|
section.left_margin = Cm(1.5)
|
|
section.right_margin = Cm(1.5)
|
|
section.top_margin = Cm(1.5)
|
|
section.bottom_margin = Cm(1.5)
|
|
|
|
month_label = _MONTH_NAMES_HR[month - 1]
|
|
servicer_name = _user_display_name(request.user) or getattr(request.user, 'username', str(request.user))
|
|
|
|
p_title = doc.add_paragraph()
|
|
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_title = p_title.add_run('MJESEČNI IZVJEŠTAJ TROŠKOVA SERVISERA')
|
|
r_title.bold = True
|
|
r_title.font.size = Pt(14)
|
|
|
|
p_sub = doc.add_paragraph()
|
|
p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r_sub = p_sub.add_run(f"{servicer_name} — {month_label} {year}")
|
|
r_sub.font.size = Pt(11)
|
|
|
|
headers = ['DATUM', 'NAZIV RAČUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG']
|
|
col_widths = [Cm(2.5), Cm(6.0), Cm(4.0), Cm(8.0), Cm(3.0)]
|
|
|
|
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
|
|
table.style = 'Table Grid'
|
|
|
|
hdr_cells = table.rows[0].cells
|
|
for i, (hdr, w) in enumerate(zip(headers, col_widths)):
|
|
hdr_cells[i].width = w
|
|
hdr_cells[i].text = hdr
|
|
if hdr_cells[i].paragraphs[0].runs:
|
|
run_h = hdr_cells[i].paragraphs[0].runs[0]
|
|
run_h.bold = True
|
|
run_h.font.size = Pt(9)
|
|
|
|
for ri, row_data in enumerate(rows):
|
|
data_cells = table.rows[ri + 1].cells
|
|
for ci, (val, w) in enumerate(zip(row_data, col_widths)):
|
|
data_cells[ci].width = w
|
|
data_cells[ci].text = str(val)
|
|
if data_cells[ci].paragraphs[0].runs:
|
|
data_cells[ci].paragraphs[0].runs[0].font.size = Pt(9)
|
|
|
|
buf = BytesIO()
|
|
doc.save(buf)
|
|
buf.seek(0)
|
|
|
|
month_key = f"{year}-{month:02d}"
|
|
safe_name = re.sub(r'[^\w\-]', '_', servicer_name)
|
|
fname = f"{safe_name}.troskovi-servisera.{month_key}.docx"
|
|
resp = HttpResponse(
|
|
buf.read(),
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
resp['Content-Disposition'] = f'attachment; filename="{fname}"'
|
|
return resp
|
|
|
|
|
|
def _monthly_archive_prefix_for_user(user):
|
|
first_name = str(getattr(user, 'first_name', '') or '').strip()
|
|
last_name = str(getattr(user, 'last_name', '') or '').strip()
|
|
if first_name and last_name:
|
|
return f"{first_name[0].upper()}{last_name[0].upper()}"
|
|
return 'MT'
|
|
|
|
|
|
def _generated_archive_filename_for_user(user, *, year, month, archive_type):
|
|
prefix = _monthly_archive_prefix_for_user(user)
|
|
suffix = 'SN' if archive_type == 'service_tasks' else 'PN'
|
|
return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
|
|
|
|
|
|
def _unique_zip_entry_name(entry_name, used_names):
|
|
candidate = entry_name
|
|
entry_path = Path(entry_name)
|
|
stem = entry_path.stem
|
|
suffix = entry_path.suffix
|
|
parent = str(entry_path.parent)
|
|
counter = 2
|
|
while candidate in used_names:
|
|
filename = f"{stem}-{counter}{suffix}"
|
|
candidate = f"{parent}/{filename}" if parent not in ('', '.') else filename
|
|
counter += 1
|
|
used_names.add(candidate)
|
|
return candidate
|
|
|
|
|
|
def _parse_year_month_params(request):
|
|
from datetime import date as _dt_date
|
|
|
|
raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None
|
|
raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None
|
|
if raw_year in (None, ''):
|
|
raw_year = request.query_params.get('year', _dt_date.today().year)
|
|
if raw_month in (None, ''):
|
|
raw_month = request.query_params.get('month', _dt_date.today().month)
|
|
|
|
try:
|
|
year = int(raw_year)
|
|
month = int(raw_month)
|
|
if not (1 <= month <= 12):
|
|
raise ValueError()
|
|
except (ValueError, TypeError):
|
|
raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'})
|
|
return year, month
|
|
|
|
|
|
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
|
from modules.task_management.models import Task
|
|
|
|
tasks = list(
|
|
Task.objects
|
|
.filter(
|
|
assigned_to=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
work_order__isnull=False,
|
|
work_order__is_active=True,
|
|
)
|
|
.select_related('work_order', 'vehicle', 'work_hours_table')
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
if not tasks:
|
|
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
|
|
|
used_names = set()
|
|
archive_buffer = BytesIO()
|
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
for task in tasks:
|
|
work_order = task.work_order
|
|
if work_order is None:
|
|
continue
|
|
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
|
base_name = _service_records_docx_filename(work_order, task)
|
|
entry_name = _unique_zip_entry_name(base_name, used_names)
|
|
archive.writestr(entry_name, docx_bytes)
|
|
|
|
archive_content = archive_buffer.getvalue()
|
|
if not archive_content:
|
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
|
return archive_content
|
|
|
|
|
|
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
|
from modules.task_management.models import Task
|
|
|
|
monthly_tasks = (
|
|
Task.objects
|
|
.filter(
|
|
assigned_to=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
work_order__isnull=False,
|
|
work_order__is_active=True,
|
|
)
|
|
.select_related('work_order')
|
|
.order_by('scheduled_date', 'created_at')
|
|
)
|
|
ordered_work_order_ids = []
|
|
seen_work_order_ids = set()
|
|
for task in monthly_tasks:
|
|
if not task.work_order_id or task.work_order_id in seen_work_order_ids:
|
|
continue
|
|
seen_work_order_ids.add(task.work_order_id)
|
|
ordered_work_order_ids.append(task.work_order_id)
|
|
if not ordered_work_order_ids:
|
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
|
|
|
work_orders_map = {
|
|
work_order.id: work_order
|
|
for work_order in (
|
|
WorkOrder.objects
|
|
.filter(id__in=ordered_work_order_ids, is_active=True)
|
|
.select_related('vehicle', 'vehicle__client', 'creator')
|
|
)
|
|
}
|
|
work_orders = [work_orders_map[work_order_id] for work_order_id in ordered_work_order_ids if work_order_id in work_orders_map]
|
|
if not work_orders:
|
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
|
|
|
used_names = set()
|
|
archive_buffer = BytesIO()
|
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
for work_order in work_orders:
|
|
pdf_bytes = _build_work_order_pdf(work_order)
|
|
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
|
archive.writestr(work_order_pdf_name, pdf_bytes)
|
|
|
|
display_code = _work_order_display_code(work_order)
|
|
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
|
for index, invoice in enumerate(invoices, start=1):
|
|
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
|
|
if not attachment:
|
|
continue
|
|
invoice_filename, content, _content_type = attachment
|
|
archive_path = f"Racuni/{display_code}/{invoice_filename}"
|
|
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
|
|
|
|
archive_content = archive_buffer.getvalue()
|
|
if not archive_content:
|
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
|
return archive_content
|
|
|
|
|
|
def _cleanup_expired_generated_archive_records():
|
|
now = timezone.now()
|
|
expired = GeneratedFleetArchive.objects.filter(
|
|
is_active=True,
|
|
expires_at__isnull=False,
|
|
expires_at__lte=now,
|
|
)
|
|
for item in expired:
|
|
if item.file:
|
|
item.file.delete(save=False)
|
|
item.is_active = False
|
|
item.status = 'failed'
|
|
item.error_message = 'ZIP arhiva je istekla.'
|
|
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
|
|
|
|
def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None):
|
|
if user is None:
|
|
return
|
|
month_label = f"{month:02d}.{year}."
|
|
if archive_type == 'service_tasks':
|
|
archive_label = 'pojedinačnih servisnih taskova'
|
|
else:
|
|
archive_label = 'putnih naloga i računa'
|
|
|
|
if stage == 'requested':
|
|
title = "ZIP arhiva u pripremi"
|
|
message = f"Zaprimljen je zahtjev za generiranje ZIP arhive {archive_label} za {month_label}"
|
|
level = 'info'
|
|
elif stage == 'failed':
|
|
title = "Greška kod ZIP arhive"
|
|
message = f"Generiranje ZIP arhive {archive_label} nije uspjelo za {month_label}"
|
|
level = 'warning'
|
|
else:
|
|
title = "ZIP arhiva spremna"
|
|
message = f"ZIP arhiva {archive_label} je spremna za preuzimanje ({month_label})"
|
|
level = 'success'
|
|
|
|
metadata = {
|
|
'entity_type': 'fleet_archive',
|
|
'archive_type': archive_type,
|
|
'stage': stage,
|
|
'year': year,
|
|
'month': month,
|
|
'section': 'service-records',
|
|
}
|
|
if generated_archive and generated_archive.pk:
|
|
metadata['generated_archive_id'] = str(generated_archive.pk)
|
|
metadata['download_url'] = f"fleet/reports/generated-archives/{generated_archive.pk}/download/"
|
|
metadata['filename'] = generated_archive.filename or _generated_archive_filename_for_user(
|
|
user,
|
|
year=year,
|
|
month=month,
|
|
archive_type=archive_type,
|
|
)
|
|
if generated_archive.expires_at:
|
|
metadata['expires_at'] = generated_archive.expires_at.isoformat()
|
|
|
|
NotificationService.create_notification(
|
|
recipient=user,
|
|
title=title,
|
|
message=message,
|
|
level=level,
|
|
send_email=False,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _get_cached_generated_archive(*, user, archive_type, year, month):
|
|
return (
|
|
GeneratedFleetArchive.objects
|
|
.filter(
|
|
is_active=True,
|
|
requested_by=user,
|
|
archive_type=archive_type,
|
|
year=year,
|
|
month=month,
|
|
status='ready',
|
|
expires_at__gt=timezone.now(),
|
|
)
|
|
.exclude(file='')
|
|
.exclude(file__isnull=True)
|
|
.order_by('-created_at')
|
|
.first()
|
|
)
|
|
|
|
|
|
def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
|
|
from modules.task_management.models import Task
|
|
|
|
base_tasks = Task.objects.filter(
|
|
assigned_to=user,
|
|
is_active=True,
|
|
scheduled_date__year=year,
|
|
scheduled_date__month=month,
|
|
work_order__isnull=False,
|
|
work_order__is_active=True,
|
|
)
|
|
latest_candidates = [
|
|
base_tasks.aggregate(value=Max('updated_at')).get('value'),
|
|
base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'),
|
|
base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'),
|
|
base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'),
|
|
]
|
|
if archive_type == 'work_orders':
|
|
latest_candidates.append(
|
|
base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value')
|
|
)
|
|
latest_values = [value for value in latest_candidates if value is not None]
|
|
if not latest_values:
|
|
return None
|
|
return max(latest_values)
|
|
|
|
|
|
def _schedule_monthly_archive_local_fallback(generated_archive_id):
|
|
def _runner():
|
|
try:
|
|
build_monthly_archive_cached_task.apply(args=[generated_archive_id]).get()
|
|
cleanup_expired_generated_archives_task.apply().get()
|
|
except Exception as exc:
|
|
logger.exception("Lokalni fallback za ZIP arhivu nije uspio: %s", exc)
|
|
|
|
threading.Thread(target=_runner, daemon=True).start()
|
|
|
|
|
|
def _request_monthly_archive_generation(*, request, archive_type):
|
|
year, month = _parse_year_month_params(request)
|
|
_cleanup_expired_generated_archive_records()
|
|
|
|
cached = _get_cached_generated_archive(
|
|
user=request.user,
|
|
archive_type=archive_type,
|
|
year=year,
|
|
month=month,
|
|
)
|
|
if cached:
|
|
if archive_type == 'service_tasks':
|
|
cached.is_active = False
|
|
cached.status = 'failed'
|
|
cached.error_message = 'ZIP arhiva servisnih taskova se regenerira za najnoviji kontekst.'
|
|
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
else:
|
|
latest_source_update = _latest_monthly_archive_source_update(
|
|
user=request.user,
|
|
archive_type=archive_type,
|
|
year=year,
|
|
month=month,
|
|
)
|
|
if latest_source_update and cached.updated_at and cached.updated_at < latest_source_update:
|
|
cached.is_active = False
|
|
cached.status = 'failed'
|
|
cached.error_message = 'ZIP arhiva zastarjela zbog novijih izmjena izvora.'
|
|
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
else:
|
|
_notify_monthly_archive_request(
|
|
user=request.user,
|
|
archive_type=archive_type,
|
|
stage='completed',
|
|
year=year,
|
|
month=month,
|
|
generated_archive=cached,
|
|
)
|
|
return {
|
|
'status': 'ready',
|
|
'generated_archive_id': str(cached.pk),
|
|
'download_url': f"fleet/reports/generated-archives/{cached.pk}/download/",
|
|
'filename': cached.filename,
|
|
'expires_at': cached.expires_at.isoformat() if cached.expires_at else None,
|
|
}
|
|
|
|
existing_pending = (
|
|
GeneratedFleetArchive.objects
|
|
.filter(
|
|
is_active=True,
|
|
requested_by=request.user,
|
|
archive_type=archive_type,
|
|
year=year,
|
|
month=month,
|
|
status='pending',
|
|
)
|
|
.order_by('-created_at')
|
|
.first()
|
|
)
|
|
if existing_pending:
|
|
stale_pending_threshold = timezone.now() - timedelta(minutes=3)
|
|
if existing_pending.created_at and existing_pending.created_at < stale_pending_threshold:
|
|
existing_pending.is_active = False
|
|
existing_pending.status = 'failed'
|
|
existing_pending.error_message = 'ZIP zahtjev je ostao predugo u pending statusu; pokrece se novi zahtjev.'
|
|
existing_pending.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
|
else:
|
|
return {
|
|
'status': 'processing',
|
|
'generated_archive_id': str(existing_pending.pk),
|
|
}
|
|
|
|
generated_archive = GeneratedFleetArchive.objects.create(
|
|
requested_by=request.user,
|
|
archive_type=archive_type,
|
|
year=year,
|
|
month=month,
|
|
status='pending',
|
|
filename=_generated_archive_filename_for_user(
|
|
request.user,
|
|
year=year,
|
|
month=month,
|
|
archive_type=archive_type,
|
|
),
|
|
expires_at=timezone.now() + timedelta(days=GENERATED_ARCHIVE_TTL_DAYS),
|
|
)
|
|
_notify_monthly_archive_request(
|
|
user=request.user,
|
|
archive_type=archive_type,
|
|
stage='requested',
|
|
year=year,
|
|
month=month,
|
|
generated_archive=generated_archive,
|
|
)
|
|
|
|
try:
|
|
build_monthly_archive_cached_task.delay(str(generated_archive.pk))
|
|
cleanup_expired_generated_archives_task.delay()
|
|
except KombuOperationalError:
|
|
_schedule_monthly_archive_local_fallback(str(generated_archive.pk))
|
|
|
|
return {
|
|
'status': 'processing',
|
|
'generated_archive_id': str(generated_archive.pk),
|
|
}
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_service_tasks_archive(request):
|
|
year, month = _parse_year_month_params(request)
|
|
archive_content = _build_monthly_service_tasks_archive_content(
|
|
user=request.user,
|
|
year=year,
|
|
month=month,
|
|
)
|
|
archive_filename = _generated_archive_filename_for_user(
|
|
request.user,
|
|
year=year,
|
|
month=month,
|
|
archive_type='service_tasks',
|
|
)
|
|
response = HttpResponse(archive_content, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename="{archive_filename}"'
|
|
return response
|
|
|
|
|
|
@api_view(['POST'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_service_tasks_archive_request(request):
|
|
payload = _request_monthly_archive_generation(request=request, archive_type='service_tasks')
|
|
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_work_orders_archive(request):
|
|
year, month = _parse_year_month_params(request)
|
|
archive_content = _build_monthly_work_orders_archive_content(
|
|
user=request.user,
|
|
year=year,
|
|
month=month,
|
|
)
|
|
archive_filename = _generated_archive_filename_for_user(
|
|
request.user,
|
|
year=year,
|
|
month=month,
|
|
archive_type='work_orders',
|
|
)
|
|
response = HttpResponse(archive_content, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename="{archive_filename}"'
|
|
return response
|
|
|
|
|
|
@api_view(['POST'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def monthly_work_orders_archive_request(request):
|
|
payload = _request_monthly_archive_generation(request=request, archive_type='work_orders')
|
|
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
|
|
|
|
|
|
@api_view(['GET'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def generated_archive_download(request, archive_id):
|
|
_cleanup_expired_generated_archive_records()
|
|
generated_archive = (
|
|
GeneratedFleetArchive.objects
|
|
.filter(
|
|
is_active=True,
|
|
requested_by=request.user,
|
|
pk=archive_id,
|
|
status='ready',
|
|
expires_at__gt=timezone.now(),
|
|
)
|
|
.exclude(file='')
|
|
.exclude(file__isnull=True)
|
|
.first()
|
|
)
|
|
if generated_archive is None:
|
|
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
|
|
|
|
generated_archive.file.open('rb')
|
|
try:
|
|
archive_bytes = generated_archive.file.read()
|
|
finally:
|
|
generated_archive.file.close()
|
|
if not archive_bytes:
|
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
|
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
|
request.user,
|
|
year=generated_archive.year,
|
|
month=generated_archive.month,
|
|
archive_type=generated_archive.archive_type,
|
|
)
|
|
response = HttpResponse(archive_bytes, content_type='application/zip')
|
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
response['Cache-Control'] = 'private, max-age=3600'
|
|
response['Content-Length'] = str(len(archive_bytes))
|
|
return response
|
|
|
|
|
|
@api_view(['POST'])
|
|
@permission_classes([permissions.IsAuthenticated])
|
|
def pusher_auth(request):
|
|
"""
|
|
Authenticate user for private Pusher channel.
|
|
Endpoint koji frontend poziva za auth na privatnom kanalu.
|
|
"""
|
|
socket_id = request.data.get('socket_id')
|
|
channel_name = request.data.get('channel_name')
|
|
|
|
if not socket_id or not channel_name:
|
|
return Response({'error': 'Missing socket_id or channel_name'}, status=400)
|
|
|
|
auth_data = PusherService.authenticate_channel(
|
|
socket_id=socket_id,
|
|
channel_name=channel_name,
|
|
user_id=request.user.id
|
|
)
|
|
|
|
if not auth_data:
|
|
return Response({'error': 'Authentication failed'}, status=403)
|
|
|
|
return Response(auth_data)
|
|
|
|
class VehicleViewSet(viewsets.ModelViewSet):
|
|
serializer_class = VehicleSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle')
|
|
|
|
|
|
class CraneViewSet(viewsets.ModelViewSet):
|
|
serializer_class = CraneSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane')
|
|
|
|
|
|
class WorkOrderViewSet(viewsets.ModelViewSet):
|
|
serializer_class = WorkOrderSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _work_orders_queryset_for_user(self.request.user)
|
|
|
|
def perform_update(self, serializer):
|
|
work_order = serializer.save()
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'service_records', 'invoices'])
|
|
|
|
@action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser])
|
|
def images(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
if request.method.lower() == 'post':
|
|
serializer = WorkOrderPhotoSerializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(work_order=work_order, uploaded_by=request.user)
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
image_format, _ = _parse_format(request.query_params.get('fmt'))
|
|
photos_qs = WorkOrderPhoto.objects.filter(
|
|
is_active=True,
|
|
work_order_id=work_order.pk,
|
|
).select_related('uploaded_by').order_by('-created_at')
|
|
|
|
images = []
|
|
for photo in photos_qs:
|
|
file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk})
|
|
original_url = request.build_absolute_uri(file_path)
|
|
optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}")
|
|
images.append({
|
|
"id": photo.pk,
|
|
"work_order_id": photo.work_order_id,
|
|
"description": photo.description,
|
|
"original_url": original_url,
|
|
"optimized_url": optimized_url,
|
|
"created_at": photo.created_at,
|
|
})
|
|
|
|
return Response({
|
|
"work_order_id": work_order.pk,
|
|
"vehicle_id": work_order.vehicle_id,
|
|
"image_quality": quality,
|
|
"image_width": width,
|
|
"image_format": image_format.lower(),
|
|
"images": images,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['get'], url_path=r'images/(?P<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."})
|
|
file_name = getattr(generated_pdf.file, 'name', '')
|
|
storage = getattr(generated_pdf.file, 'storage', None)
|
|
if not file_name or storage is None:
|
|
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
try:
|
|
if not storage.exists(file_name):
|
|
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
except (FileNotFoundError, OSError, ValueError):
|
|
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
|
|
|
|
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
|
def pdf_preview(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
|
invoice_rows = []
|
|
for invoice in invoices:
|
|
image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None
|
|
datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
|
|
invoice_rows.append(
|
|
f"""
|
|
<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'}
|
|
)
|
|
travel_expenses_context = _work_order_travel_expenses_context(work_order)
|
|
travel_expenses_table = getattr(work_order, 'travel_expenses_table', None)
|
|
travel_expenses_payload = (
|
|
WorkOrderTravelExpensesTableSerializer(travel_expenses_table).data
|
|
if travel_expenses_table
|
|
else {
|
|
'work_order': str(work_order.pk),
|
|
'broj_sati': _format_decimal_fixed(travel_expenses_context['broj_sati']),
|
|
'kolicina_dnevnica': _format_decimal_fixed(travel_expenses_context['kolicina_dnevnica']),
|
|
'iznos_dnevnica': _format_decimal_fixed(travel_expenses_context['iznos_dnevnica']),
|
|
'daily_rate_country': WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY,
|
|
'total_for_payout': _format_decimal_fixed(travel_expenses_context['total_for_payout']),
|
|
}
|
|
)
|
|
|
|
return Response({
|
|
'work_order_id': work_order.pk,
|
|
'tasks': payload,
|
|
'additional_costs_table': additional_costs_payload,
|
|
'travel_expenses_table': travel_expenses_payload,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
|
|
def additional_costs_table(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
table = getattr(work_order, 'additional_costs_table', None)
|
|
|
|
if request.method.lower() == 'get':
|
|
if table:
|
|
return Response(WorkOrderAdditionalCostsTableSerializer(table).data, status=status.HTTP_200_OK)
|
|
return Response(
|
|
{'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
if isinstance(request.data, dict) and 'data' in request.data:
|
|
payload_data = request.data.get('data')
|
|
else:
|
|
payload_data = request.data
|
|
if payload_data == {}:
|
|
payload_data = {'rows': []}
|
|
|
|
serializer = WorkOrderAdditionalCostsTableSerializer(
|
|
table,
|
|
data={'work_order': str(work_order.pk), 'data': payload_data},
|
|
partial=bool(table),
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
instance = serializer.save(work_order=work_order)
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
|
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['get', 'put', 'patch'], url_path='travel-expenses-table')
|
|
def travel_expenses_table(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
table = getattr(work_order, 'travel_expenses_table', None)
|
|
fallback = _work_order_travel_expenses_context(work_order)
|
|
|
|
if request.method.lower() == 'get':
|
|
if table:
|
|
return Response(WorkOrderTravelExpensesTableSerializer(table).data, status=status.HTTP_200_OK)
|
|
return Response({
|
|
'work_order': str(work_order.pk),
|
|
'broj_sati': _format_decimal_fixed(fallback['broj_sati']),
|
|
'kolicina_dnevnica': _format_decimal_fixed(fallback['kolicina_dnevnica']),
|
|
'iznos_dnevnica': _format_decimal_fixed(fallback['iznos_dnevnica']),
|
|
'daily_rate_country': fallback['daily_rate_country'],
|
|
'total_for_payout': _format_decimal_fixed(fallback['total_for_payout']),
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
serializer = WorkOrderTravelExpensesTableSerializer(
|
|
table,
|
|
data={
|
|
'work_order': str(work_order.pk),
|
|
'broj_sati': request.data.get('broj_sati', fallback['broj_sati']),
|
|
'kolicina_dnevnica': request.data.get('kolicina_dnevnica', fallback['kolicina_dnevnica']),
|
|
'iznos_dnevnica': request.data.get('iznos_dnevnica', fallback['iznos_dnevnica']),
|
|
'daily_rate_country': request.data.get('daily_rate_country', fallback['daily_rate_country']),
|
|
},
|
|
partial=bool(table),
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
instance = serializer.save(work_order=work_order)
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order'])
|
|
return Response(WorkOrderTravelExpensesTableSerializer(instance).data, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=True, methods=['post'], url_path='send-email')
|
|
def send_email(self, request, pk=None):
|
|
work_order = self.get_object()
|
|
recipients = _parse_recipients(
|
|
request.data,
|
|
fallback_candidates=[
|
|
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
|
|
getattr(work_order.creator, 'email', None),
|
|
],
|
|
)
|
|
if not recipients:
|
|
raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."})
|
|
|
|
include_work_order_pdf = _parse_boolean_flag(
|
|
request.data.get('include_work_order_pdf'),
|
|
field_name='include_work_order_pdf',
|
|
default=True,
|
|
)
|
|
include_service_records_pdf = _parse_boolean_flag(
|
|
request.data.get('include_service_records_pdf'),
|
|
field_name='include_service_records_pdf',
|
|
default=False,
|
|
)
|
|
include_invoices_pdf = _parse_boolean_flag(
|
|
request.data.get('include_invoices_pdf'),
|
|
field_name='include_invoices_pdf',
|
|
default=False,
|
|
)
|
|
include_images = _parse_boolean_flag(
|
|
request.data.get('include_images'),
|
|
field_name='include_images',
|
|
default=False,
|
|
)
|
|
include_monthly_tasks = _parse_boolean_flag(
|
|
request.data.get('include_monthly_tasks'),
|
|
field_name='include_monthly_tasks',
|
|
default=False,
|
|
)
|
|
selected_note = _resolve_service_note_for_email(
|
|
user=request.user,
|
|
note_id=request.data.get('service_note_id') or request.data.get('note_id'),
|
|
work_order=work_order,
|
|
)
|
|
if not any([
|
|
include_work_order_pdf,
|
|
include_service_records_pdf,
|
|
include_invoices_pdf,
|
|
include_images,
|
|
include_monthly_tasks,
|
|
]):
|
|
raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."})
|
|
month_year = None
|
|
month_number = None
|
|
month_key = ''
|
|
if include_monthly_tasks:
|
|
month_year, month_number, month_key = _parse_month_value(request.data.get('month'))
|
|
|
|
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
|
|
message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga."
|
|
selected_attachment_flags = []
|
|
if include_work_order_pdf:
|
|
selected_attachment_flags.append({'type': 'work_order_pdf'})
|
|
if include_service_records_pdf:
|
|
selected_attachment_flags.append({'type': 'service_records_pdf'})
|
|
if include_invoices_pdf:
|
|
selected_attachment_flags.append({'type': 'invoices_pdf'})
|
|
if include_images:
|
|
selected_attachment_flags.append({'type': 'images'})
|
|
if include_monthly_tasks:
|
|
selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key})
|
|
|
|
dispatch_log = EmailDispatchLog.objects.create(
|
|
dispatch_type='work_order',
|
|
requested_by=request.user,
|
|
work_order=work_order,
|
|
recipients=recipients,
|
|
subject=subject,
|
|
message=message,
|
|
attachments=selected_attachment_flags,
|
|
status='queued',
|
|
metadata={
|
|
'service_note_id': str(selected_note.pk) if selected_note else None,
|
|
},
|
|
)
|
|
|
|
NotificationService.create_notification(
|
|
recipient=request.user,
|
|
title='Slanje emaila u obradi',
|
|
message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.',
|
|
level='info',
|
|
send_email=False,
|
|
metadata={
|
|
'entity_type': 'work_order_email',
|
|
'stage': 'requested',
|
|
'work_order_id': str(work_order.pk),
|
|
'recipients_count': len(recipients),
|
|
},
|
|
)
|
|
dispatch_backend = _dispatch_work_order_email_background(
|
|
{
|
|
'work_order_id': str(work_order.pk),
|
|
'requested_by_id': str(request.user.id),
|
|
'recipients': recipients,
|
|
'subject': subject,
|
|
'message': message,
|
|
'include_work_order_pdf': include_work_order_pdf,
|
|
'include_service_records_pdf': include_service_records_pdf,
|
|
'include_invoices_pdf': include_invoices_pdf,
|
|
'include_images': include_images,
|
|
'include_monthly_tasks': include_monthly_tasks,
|
|
'month_year': month_year,
|
|
'month_number': month_number,
|
|
'month_key': month_key,
|
|
'service_note_id': str(selected_note.pk) if selected_note else None,
|
|
'dispatch_log_id': str(dispatch_log.pk),
|
|
}
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"queued": True,
|
|
"recipients": recipients,
|
|
"work_order_id": str(work_order.pk),
|
|
"dispatch_backend": dispatch_backend,
|
|
},
|
|
status=status.HTTP_202_ACCEPTED,
|
|
)
|
|
|
|
|
|
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
|
serializer_class = VehicleServiceRecordSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
return _service_records_queryset_for_user(user)
|
|
|
|
@action(detail=True, methods=['get'], url_path='pdf')
|
|
def pdf(self, request, pk=None):
|
|
service_record = self.get_object()
|
|
pdf_bytes = _build_service_record_pdf(service_record)
|
|
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
|
|
return response
|
|
|
|
@action(detail=True, methods=['post'], url_path='send-email')
|
|
def send_email(self, request, pk=None):
|
|
service_record = self.get_object()
|
|
recipient = _first_non_empty(
|
|
request.data.get('recipient'),
|
|
getattr(getattr(service_record.vehicle, 'client', None), 'email', None),
|
|
getattr(service_record.performed_by, 'email', None),
|
|
)
|
|
if not recipient:
|
|
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
|
|
pdf_bytes = _build_service_record_pdf(service_record)
|
|
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
|
|
body = _first_non_empty(request.data.get('message')) or (
|
|
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
|
|
)
|
|
body_with_signature = append_user_signature(body, request.user)
|
|
_send_document_email(
|
|
recipient=recipient,
|
|
subject=subject,
|
|
body=body_with_signature,
|
|
filename=f"{service_record.pk}.service-record.pdf",
|
|
pdf_bytes=pdf_bytes,
|
|
)
|
|
EmailDispatchLog.objects.create(
|
|
dispatch_type='service_record',
|
|
requested_by=request.user,
|
|
service_record=service_record,
|
|
recipients=[recipient],
|
|
subject=subject,
|
|
message=body_with_signature,
|
|
attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}],
|
|
status='sent',
|
|
sent_at=timezone.now(),
|
|
)
|
|
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
|
serializer_class = WorkOrderInvoiceSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
work_order_id = self.request.query_params.get('work_order_id')
|
|
year = self.request.query_params.get('year')
|
|
month = self.request.query_params.get('month')
|
|
qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
|
|
if year and month:
|
|
try:
|
|
year_value = int(year)
|
|
month_value = int(month)
|
|
except (TypeError, ValueError):
|
|
year_value = None
|
|
month_value = None
|
|
if year_value and month_value and 1 <= month_value <= 12:
|
|
qs = qs.filter(
|
|
work_order__work_order_tasks__scheduled_date__year=year_value,
|
|
work_order__work_order_tasks__scheduled_date__month=month_value,
|
|
)
|
|
if user.is_staff:
|
|
if work_order_id:
|
|
qs = qs.filter(work_order_id=work_order_id)
|
|
return qs.distinct()
|
|
allowed_orders = _work_orders_queryset_for_user(user).values('id')
|
|
qs = qs.filter(work_order_id__in=allowed_orders)
|
|
if work_order_id:
|
|
qs = qs.filter(work_order_id=work_order_id)
|
|
return qs.distinct()
|
|
|
|
def get_serializer_context(self):
|
|
context = super().get_serializer_context()
|
|
context['request'] = self.request
|
|
return context
|
|
|
|
@action(detail=True, methods=['get'], url_path='image')
|
|
def image(self, request, pk=None):
|
|
invoice = self.get_object()
|
|
if not invoice.image:
|
|
raise DRFValidationError({"detail": "Datoteka računa nije dostupna."})
|
|
|
|
suffix = Path(invoice.image.name or '').suffix.lower()
|
|
if suffix == '.pdf':
|
|
invoice.image.open('rb')
|
|
try:
|
|
response = HttpResponse(invoice.image.read(), content_type='application/pdf')
|
|
finally:
|
|
invoice.image.close()
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"'
|
|
return response
|
|
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
|
|
|
try:
|
|
invoice.image.open('rb')
|
|
with Image.open(invoice.image) as source:
|
|
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
|
if width and image.width > width:
|
|
ratio = width / float(image.width)
|
|
target_height = max(1, int(image.height * ratio))
|
|
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
|
|
buffer = BytesIO()
|
|
save_kwargs = {"format": image_format, "optimize": True}
|
|
if image_format in ('WEBP', 'JPEG'):
|
|
save_kwargs["quality"] = quality
|
|
image.save(buffer, **save_kwargs)
|
|
except (UnidentifiedImageError, OSError):
|
|
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
|
finally:
|
|
invoice.image.close()
|
|
|
|
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"'
|
|
return response
|
|
|
|
def perform_create(self, serializer):
|
|
work_order = serializer.validated_data.get('work_order')
|
|
if work_order is None:
|
|
raise DRFValidationError({"work_order": "Putni nalog je obavezan."})
|
|
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
|
|
if not allowed:
|
|
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
|
|
invoice = serializer.save(created_by=self.request.user)
|
|
_upsert_additional_cost_row_from_invoice(invoice)
|
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
|
try:
|
|
process_work_order_invoice_ocr.delay(str(invoice.pk))
|
|
except KombuOperationalError:
|
|
process_work_order_invoice_ocr.apply(args=[str(invoice.pk)])
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active'])
|
|
|
|
|
|
class ServiceContextNoteViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return _service_context_notes_queryset_for_user(self.request.user)
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == 'create':
|
|
return ServiceContextNoteCreateSerializer
|
|
return ServiceContextNoteSerializer
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data, context={'request': request})
|
|
serializer.is_valid(raise_exception=True)
|
|
payload = serializer.validated_data
|
|
user = request.user
|
|
audience = payload.get('audience', 'self')
|
|
target_users = payload.get('target_users') or []
|
|
note_text = payload.get('note')
|
|
note_date = payload.get('note_date')
|
|
work_order = payload.get('work_order')
|
|
task = payload.get('task')
|
|
is_supervisor = _is_supervisor_user(user)
|
|
can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False))
|
|
|
|
if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists():
|
|
raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'})
|
|
if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id):
|
|
raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'})
|
|
|
|
recipients = []
|
|
if audience == 'self':
|
|
recipients = [user]
|
|
elif audience == 'member':
|
|
recipients = list(target_users)
|
|
else:
|
|
if not can_manage_recipients:
|
|
raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'})
|
|
recipients = list(_active_team_members_queryset())
|
|
if not recipients:
|
|
raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'})
|
|
|
|
created_notes = []
|
|
seen = OrderedDict()
|
|
for recipient in recipients:
|
|
seen[str(recipient.id)] = recipient
|
|
with transaction.atomic():
|
|
for recipient in seen.values():
|
|
note = ServiceContextNote.objects.create(
|
|
created_by=user,
|
|
recipient=recipient,
|
|
audience_source=audience,
|
|
work_order=work_order,
|
|
task=task,
|
|
note=note_text,
|
|
note_date=note_date,
|
|
)
|
|
created_notes.append(note)
|
|
NotificationService.create_notification(
|
|
recipient=recipient,
|
|
title='Nova bilješka servisnog konteksta',
|
|
message=note_text[:255],
|
|
level='info',
|
|
metadata={
|
|
'entity_type': 'service_note',
|
|
'service_note_id': str(note.id),
|
|
'work_order_id': str(work_order.id) if work_order else None,
|
|
'task_id': str(task.id) if task else None,
|
|
'section': 'dashboard',
|
|
'note_date': note_date.isoformat() if note_date else None,
|
|
},
|
|
send_email=False,
|
|
)
|
|
|
|
response_serializer = ServiceContextNoteSerializer(
|
|
_service_context_notes_queryset_for_user(user)[:20],
|
|
many=True,
|
|
)
|
|
return Response(
|
|
{
|
|
'created_count': len(created_notes),
|
|
'notes': response_serializer.data,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='close')
|
|
def close(self, request, pk=None):
|
|
note = self.get_object()
|
|
if note.is_closed:
|
|
serializer = ServiceContextNoteSerializer(note)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
note.is_closed = True
|
|
note.closed_at = timezone.now()
|
|
note.save(update_fields=['is_closed', 'closed_at', 'updated_at'])
|
|
serializer = ServiceContextNoteSerializer(note)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
|
"""
|
|
List all notifications for the authenticated user and provide an action
|
|
to mark a notification as read.
|
|
"""
|
|
serializer_class = VehicleNotificationSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
# Prikaži samo aktivne notifikacije za trenutnog korisnika
|
|
user = self.request.user
|
|
return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at')
|
|
|
|
@action(detail=True, methods=['post'], url_path='mark-read')
|
|
def mark_read(self, request, pk=None):
|
|
"""
|
|
Označi notifikaciju kao pročitanu.
|
|
URL: POST /api/fleet/notifications/{pk}/mark-read/
|
|
"""
|
|
notif = self.get_object()
|
|
# sigurnosna provjera: dozvoljeno samo primatelju
|
|
if notif.recipient != request.user:
|
|
raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.")
|
|
|
|
notif.is_read = True
|
|
notif.save(update_fields=['is_read'])
|
|
|
|
serializer = self.get_serializer(notif)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
@action(detail=False, methods=['post'], url_path='mark-all-read')
|
|
def mark_all_read(self, request):
|
|
"""
|
|
Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane.
|
|
URL: POST /api/fleet/notifications/mark-all-read/
|
|
"""
|
|
user = request.user
|
|
qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False)
|
|
updated_count = qs.update(is_read=True)
|
|
return Response({"marked": updated_count}, status=status.HTTP_200_OK)
|
|
|
|
class VehicleServicePhotoViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
ViewSet za upravljanje fotografijama servisnih zapisa.
|
|
Delegira sve operacije u VehicleServicePhotoService.
|
|
Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka.
|
|
"""
|
|
queryset = VehicleServicePhoto.objects.filter(is_active=True)
|
|
serializer_class = VehicleServicePhotoSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
"""Filtriraj po service_record ID-u ako je proslijeđen u query parametrima."""
|
|
service_record_id = self.request.query_params.get('service_record_id')
|
|
user = self.request.user
|
|
qs = VehicleServicePhoto.objects.filter(is_active=True)
|
|
if not user.is_staff:
|
|
qs = qs.filter(service_record__performed_by=user)
|
|
if service_record_id:
|
|
qs = qs.filter(service_record_id=service_record_id)
|
|
return qs
|
|
|
|
@action(detail=True, methods=['get'], url_path='optimized')
|
|
def optimized(self, request, pk=None):
|
|
photo = self.get_object()
|
|
if not photo.image:
|
|
raise DRFValidationError({"detail": "Fotografija nije dostupna."})
|
|
|
|
width = _parse_positive_int(
|
|
request.query_params.get('w'),
|
|
field_name='w',
|
|
default=1280,
|
|
min_value=160,
|
|
max_value=3840,
|
|
)
|
|
quality = _parse_positive_int(
|
|
request.query_params.get('q'),
|
|
field_name='q',
|
|
default=75,
|
|
min_value=30,
|
|
max_value=95,
|
|
)
|
|
image_format, extension = _parse_format(request.query_params.get('fmt'))
|
|
|
|
try:
|
|
photo.image.open('rb')
|
|
with Image.open(photo.image) as source:
|
|
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
|
|
if width and image.width > width:
|
|
ratio = width / float(image.width)
|
|
target_height = max(1, int(image.height * ratio))
|
|
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
|
|
buffer = BytesIO()
|
|
save_kwargs = {"format": image_format, "optimize": True}
|
|
if image_format in ('WEBP', 'JPEG'):
|
|
save_kwargs["quality"] = quality
|
|
image.save(buffer, **save_kwargs)
|
|
except (UnidentifiedImageError, OSError):
|
|
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
|
|
finally:
|
|
photo.image.close()
|
|
|
|
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
|
|
response['Cache-Control'] = 'private, max-age=86400'
|
|
response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"'
|
|
return response
|
|
|
|
def perform_create(self, serializer):
|
|
"""
|
|
Delegiraj stvaranje u VehicleServicePhotoService.
|
|
serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika.
|
|
"""
|
|
try:
|
|
data = serializer.validated_data
|
|
if not _can_access_service_record(self.request.user, data.get('service_record')):
|
|
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
|
|
photo = VehicleServicePhotoService.upload_photo(
|
|
data=data,
|
|
uploaded_by=self.request.user
|
|
)
|
|
serializer.instance = photo
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
def perform_update(self, serializer):
|
|
"""
|
|
Delegiraj ažuriranje u VehicleServicePhotoService.
|
|
"""
|
|
try:
|
|
instance = serializer.instance
|
|
data = serializer.validated_data
|
|
photo = VehicleServicePhotoService.update_photo(
|
|
instance=instance,
|
|
data=data,
|
|
user=self.request.user
|
|
)
|
|
serializer.instance = photo
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
def perform_destroy(self, instance):
|
|
"""
|
|
Delegiraj soft-delete u VehicleServicePhotoService.
|
|
"""
|
|
try:
|
|
VehicleServicePhotoService.delete_photo(
|
|
instance=instance,
|
|
user=self.request.user
|
|
)
|
|
except DRFValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise DRFValidationError({"detail": str(e)})
|
|
|
|
class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet):
|
|
queryset = VehicleServiceAttachment.objects.filter(is_active=True)
|
|
serializer_class = VehicleServiceAttachmentSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
parser_classes = (MultiPartParser, FormParser)
|
|
|
|
def get_queryset(self):
|
|
user = self.request.user
|
|
service_record_id = self.request.query_params.get('service_record_id')
|
|
qs = VehicleServiceAttachment.objects.filter(is_active=True)
|
|
if not user.is_staff:
|
|
qs = qs.filter(service_record__performed_by=user)
|
|
if service_record_id:
|
|
qs = qs.filter(service_record_id=service_record_id)
|
|
return qs
|
|
|
|
def perform_create(self, serializer):
|
|
if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')):
|
|
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
|
|
serializer.save(uploaded_by=self.request.user)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active']) |