Popis računa
{('
| naziv_računa | lokacija | datum | opis | slika |
' + ''.join(invoice_rows) + '
') if invoice_rows else '
Za ovaj putni nalog još nema unesenih računa.
'}
"""
return HttpResponse(html, content_type='text/html; charset=utf-8')
@action(detail=True, methods=['get'], url_path='task-service-context')
def task_service_context(self, request, pk=None):
work_order = self.get_object()
tasks = _work_order_related_tasks_queryset(work_order).prefetch_related(
'service_records',
'service_records__photos',
'service_records__performed_by',
'service_records__vehicle',
)
payload = []
for task in tasks:
records_payload = []
# Filter records by the task's own vehicle (not work_order.vehicle) to support
# cross-crane work orders where each task may belong to a different crane.
task_vehicle_id = task.vehicle_id
for record in task.service_records.filter(is_active=True, vehicle_id=task_vehicle_id).all():
photos_payload = []
for photo in record.photos.filter(is_active=True).all():
if not photo.image:
continue
optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
photos_payload.append({
'id': photo.pk,
'image_url': request.build_absolute_uri(photo.image.url),
'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"),
'description': photo.description,
})
records_payload.append({
'id': record.pk,
'service_title': record.service_title,
'description': record.description,
'service_date': record.service_date,
'cost': str(record.cost),
'mileage': record.mileage,
'performed_by_name': _user_display_name(record.performed_by),
'vehicle_registration': getattr(record.vehicle, 'registration_number', None),
'photos': photos_payload,
})
payload.append({
'id': task.pk,
'title': task.title,
'status': task.status,
'description': task.description,
'scheduled_date': task.scheduled_date,
'service_report_note': task.service_report_note or '',
'assigned_to_name': _user_display_name(task.assigned_to),
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
'task_work_order': (
{
'id': task.work_order_id,
'display_code': getattr(task.work_order, 'display_code', None),
'origin_location': getattr(task.work_order, 'origin_location', None),
'location': getattr(task.work_order, 'location', None),
'travel_start_at': getattr(task.work_order, 'travel_start_at', None),
'travel_end_at': getattr(task.work_order, 'travel_end_at', None),
'start_mileage': getattr(task.work_order, 'start_mileage', None),
'end_mileage': getattr(task.work_order, 'end_mileage', None),
'distance': getattr(task.work_order, 'distance', None),
'servicer_vehicle_registration': getattr(task.work_order, 'servicer_vehicle_registration', None),
'servicer_vehicle_make_model': getattr(task.work_order, 'servicer_vehicle_make_model', None),
}
if task.work_order_id else None
),
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None),
'service_records': records_payload,
})
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
additional_costs_payload = (
WorkOrderAdditionalCostsTableSerializer(additional_costs_table).data
if additional_costs_table
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
)
return Response({
'work_order_id': work_order.pk,
'tasks': payload,
'additional_costs_table': additional_costs_payload,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
def additional_costs_table(self, request, pk=None):
work_order = self.get_object()
table = getattr(work_order, 'additional_costs_table', None)
if request.method.lower() == 'get':
if table:
return Response(WorkOrderAdditionalCostsTableSerializer(table).data, status=status.HTTP_200_OK)
return Response(
{'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'},
status=status.HTTP_200_OK,
)
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = {'rows': []}
serializer = WorkOrderAdditionalCostsTableSerializer(
table,
data={'work_order': str(work_order.pk), 'data': payload_data},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(work_order=work_order)
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
work_order = self.get_object()
recipients = _parse_recipients(
request.data,
fallback_candidates=[
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
getattr(work_order.creator, 'email', None),
],
)
if not recipients:
raise DRFValidationError({"recipients": "Nije pronađena email adresa za slanje."})
include_work_order_pdf = _parse_boolean_flag(
request.data.get('include_work_order_pdf'),
field_name='include_work_order_pdf',
default=True,
)
include_service_records_pdf = _parse_boolean_flag(
request.data.get('include_service_records_pdf'),
field_name='include_service_records_pdf',
default=False,
)
include_invoices_pdf = _parse_boolean_flag(
request.data.get('include_invoices_pdf'),
field_name='include_invoices_pdf',
default=False,
)
include_images = _parse_boolean_flag(
request.data.get('include_images'),
field_name='include_images',
default=False,
)
include_monthly_tasks = _parse_boolean_flag(
request.data.get('include_monthly_tasks'),
field_name='include_monthly_tasks',
default=False,
)
selected_note = _resolve_service_note_for_email(
user=request.user,
note_id=request.data.get('service_note_id') or request.data.get('note_id'),
work_order=work_order,
)
if not any([
include_work_order_pdf,
include_service_records_pdf,
include_invoices_pdf,
include_images,
include_monthly_tasks,
]):
raise DRFValidationError({"detail": "Odaberite barem jedan PDF/prilog za slanje emaila."})
month_year = None
month_number = None
month_key = ''
if include_monthly_tasks:
month_year, month_number, month_key = _parse_month_value(request.data.get('month'))
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
message = _first_non_empty(request.data.get('message')) or "U prilogu je dokumentacija putnog naloga."
selected_attachment_flags = []
if include_work_order_pdf:
selected_attachment_flags.append({'type': 'work_order_pdf'})
if include_service_records_pdf:
selected_attachment_flags.append({'type': 'service_records_pdf'})
if include_invoices_pdf:
selected_attachment_flags.append({'type': 'invoices_pdf'})
if include_images:
selected_attachment_flags.append({'type': 'images'})
if include_monthly_tasks:
selected_attachment_flags.append({'type': 'monthly_tasks', 'month': month_key})
dispatch_log = EmailDispatchLog.objects.create(
dispatch_type='work_order',
requested_by=request.user,
work_order=work_order,
recipients=recipients,
subject=subject,
message=message,
attachments=selected_attachment_flags,
status='queued',
metadata={
'service_note_id': str(selected_note.pk) if selected_note else None,
},
)
NotificationService.create_notification(
recipient=request.user,
title='Slanje emaila u obradi',
message=f'Pokrenuto je slanje emaila za putni nalog {_work_order_display_code(work_order)}.',
level='info',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'requested',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients),
},
)
dispatch_backend = _dispatch_work_order_email_background(
{
'work_order_id': str(work_order.pk),
'requested_by_id': str(request.user.id),
'recipients': recipients,
'subject': subject,
'message': message,
'include_work_order_pdf': include_work_order_pdf,
'include_service_records_pdf': include_service_records_pdf,
'include_invoices_pdf': include_invoices_pdf,
'include_images': include_images,
'include_monthly_tasks': include_monthly_tasks,
'month_year': month_year,
'month_number': month_number,
'month_key': month_key,
'service_note_id': str(selected_note.pk) if selected_note else None,
'dispatch_log_id': str(dispatch_log.pk),
}
)
return Response(
{
"queued": True,
"recipients": recipients,
"work_order_id": str(work_order.pk),
"dispatch_backend": dispatch_backend,
},
status=status.HTTP_202_ACCEPTED,
)
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
serializer_class = VehicleServiceRecordSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return _service_records_queryset_for_user(user)
@action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None):
service_record = self.get_object()
pdf_bytes = _build_service_record_pdf(service_record)
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
return response
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
service_record = self.get_object()
recipient = _first_non_empty(
request.data.get('recipient'),
getattr(getattr(service_record.vehicle, 'client', None), 'email', None),
getattr(service_record.performed_by, 'email', None),
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_service_record_pdf(service_record)
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
body = _first_non_empty(request.data.get('message')) or (
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
)
body_with_signature = append_user_signature(body, request.user)
_send_document_email(
recipient=recipient,
subject=subject,
body=body_with_signature,
filename=f"{service_record.pk}.service-record.pdf",
pdf_bytes=pdf_bytes,
)
EmailDispatchLog.objects.create(
dispatch_type='service_record',
requested_by=request.user,
service_record=service_record,
recipients=[recipient],
subject=subject,
message=body_with_signature,
attachments=[{'type': 'service_record_pdf', 'filename': f"{service_record.pk}.service-record.pdf"}],
status='sent',
sent_at=timezone.now(),
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
serializer_class = WorkOrderInvoiceSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
user = self.request.user
work_order_id = self.request.query_params.get('work_order_id')
year = self.request.query_params.get('year')
month = self.request.query_params.get('month')
qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
if year and month:
try:
year_value = int(year)
month_value = int(month)
except (TypeError, ValueError):
year_value = None
month_value = None
if year_value and month_value and 1 <= month_value <= 12:
qs = qs.filter(
work_order__work_order_tasks__scheduled_date__year=year_value,
work_order__work_order_tasks__scheduled_date__month=month_value,
)
if user.is_staff:
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs.distinct()
allowed_orders = _work_orders_queryset_for_user(user).values('id')
qs = qs.filter(work_order_id__in=allowed_orders)
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs.distinct()
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
return context
@action(detail=True, methods=['get'], url_path='image')
def image(self, request, pk=None):
invoice = self.get_object()
if not invoice.image:
raise DRFValidationError({"detail": "Datoteka računa nije dostupna."})
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
invoice.image.open('rb')
try:
response = HttpResponse(invoice.image.read(), content_type='application/pdf')
finally:
invoice.image.close()
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"'
return response
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
invoice.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"'
return response
def perform_create(self, serializer):
work_order = serializer.validated_data.get('work_order')
if work_order is None:
raise DRFValidationError({"work_order": "Putni nalog je obavezan."})
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
if not allowed:
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
invoice = serializer.save(created_by=self.request.user)
_upsert_additional_cost_row_from_invoice(invoice)
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
try:
process_work_order_invoice_ocr.delay(str(invoice.pk))
except KombuOperationalError:
process_work_order_invoice_ocr.apply(args=[str(invoice.pk)])
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])
class ServiceContextNoteViewSet(
mixins.ListModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet,
):
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return _service_context_notes_queryset_for_user(self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return ServiceContextNoteCreateSerializer
return ServiceContextNoteSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
user = request.user
audience = payload.get('audience', 'self')
target_users = payload.get('target_users') or []
note_text = payload.get('note')
note_date = payload.get('note_date')
work_order = payload.get('work_order')
task = payload.get('task')
is_supervisor = _is_supervisor_user(user)
can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False))
if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists():
raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'})
if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id):
raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'})
recipients = []
if audience == 'self':
recipients = [user]
elif audience == 'member':
recipients = list(target_users)
else:
if not can_manage_recipients:
raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'})
recipients = list(_active_team_members_queryset())
if not recipients:
raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'})
created_notes = []
seen = OrderedDict()
for recipient in recipients:
seen[str(recipient.id)] = recipient
with transaction.atomic():
for recipient in seen.values():
note = ServiceContextNote.objects.create(
created_by=user,
recipient=recipient,
audience_source=audience,
work_order=work_order,
task=task,
note=note_text,
note_date=note_date,
)
created_notes.append(note)
NotificationService.create_notification(
recipient=recipient,
title='Nova bilješka servisnog konteksta',
message=note_text[:255],
level='info',
metadata={
'entity_type': 'service_note',
'service_note_id': str(note.id),
'work_order_id': str(work_order.id) if work_order else None,
'task_id': str(task.id) if task else None,
'section': 'dashboard',
'note_date': note_date.isoformat() if note_date else None,
},
send_email=False,
)
response_serializer = ServiceContextNoteSerializer(
_service_context_notes_queryset_for_user(user)[:20],
many=True,
)
return Response(
{
'created_count': len(created_notes),
'notes': response_serializer.data,
},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=['post'], url_path='close')
def close(self, request, pk=None):
note = self.get_object()
if note.is_closed:
serializer = ServiceContextNoteSerializer(note)
return Response(serializer.data, status=status.HTTP_200_OK)
note.is_closed = True
note.closed_at = timezone.now()
note.save(update_fields=['is_closed', 'closed_at', 'updated_at'])
serializer = ServiceContextNoteSerializer(note)
return Response(serializer.data, status=status.HTTP_200_OK)
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
"""
List all notifications for the authenticated user and provide an action
to mark a notification as read.
"""
serializer_class = VehicleNotificationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Prikaži samo aktivne notifikacije za trenutnog korisnika
user = self.request.user
return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at')
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
"""
Označi notifikaciju kao pročitanu.
URL: POST /api/fleet/notifications/{pk}/mark-read/
"""
notif = self.get_object()
# sigurnosna provjera: dozvoljeno samo primatelju
if notif.recipient != request.user:
raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.")
notif.is_read = True
notif.save(update_fields=['is_read'])
serializer = self.get_serializer(notif)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=['post'], url_path='mark-all-read')
def mark_all_read(self, request):
"""
Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane.
URL: POST /api/fleet/notifications/mark-all-read/
"""
user = request.user
qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False)
updated_count = qs.update(is_read=True)
return Response({"marked": updated_count}, status=status.HTTP_200_OK)
class VehicleServicePhotoViewSet(viewsets.ModelViewSet):
"""
ViewSet za upravljanje fotografijama servisnih zapisa.
Delegira sve operacije u VehicleServicePhotoService.
Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka.
"""
queryset = VehicleServicePhoto.objects.filter(is_active=True)
serializer_class = VehicleServicePhotoSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
"""Filtriraj po service_record ID-u ako je proslijeđen u query parametrima."""
service_record_id = self.request.query_params.get('service_record_id')
user = self.request.user
qs = VehicleServicePhoto.objects.filter(is_active=True)
if not user.is_staff:
qs = qs.filter(service_record__performed_by=user)
if service_record_id:
qs = qs.filter(service_record_id=service_record_id)
return qs
@action(detail=True, methods=['get'], url_path='optimized')
def optimized(self, request, pk=None):
photo = self.get_object()
if not photo.image:
raise DRFValidationError({"detail": "Fotografija nije dostupna."})
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
photo.image.open('rb')
with Image.open(photo.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
photo.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"'
return response
def perform_create(self, serializer):
"""
Delegiraj stvaranje u VehicleServicePhotoService.
serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika.
"""
try:
data = serializer.validated_data
if not _can_access_service_record(self.request.user, data.get('service_record')):
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
photo = VehicleServicePhotoService.upload_photo(
data=data,
uploaded_by=self.request.user
)
serializer.instance = photo
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
def perform_update(self, serializer):
"""
Delegiraj ažuriranje u VehicleServicePhotoService.
"""
try:
instance = serializer.instance
data = serializer.validated_data
photo = VehicleServicePhotoService.update_photo(
instance=instance,
data=data,
user=self.request.user
)
serializer.instance = photo
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
def perform_destroy(self, instance):
"""
Delegiraj soft-delete u VehicleServicePhotoService.
"""
try:
VehicleServicePhotoService.delete_photo(
instance=instance,
user=self.request.user
)
except DRFValidationError:
raise
except Exception as e:
raise DRFValidationError({"detail": str(e)})
class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet):
queryset = VehicleServiceAttachment.objects.filter(is_active=True)
serializer_class = VehicleServiceAttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
user = self.request.user
service_record_id = self.request.query_params.get('service_record_id')
qs = VehicleServiceAttachment.objects.filter(is_active=True)
if not user.is_staff:
qs = qs.filter(service_record__performed_by=user)
if service_record_id:
qs = qs.filter(service_record_id=service_record_id)
return qs
def perform_create(self, serializer):
if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')):
raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
serializer.save(uploaded_by=self.request.user)
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active'])