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 = []
for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all():
photos_payload = []
for photo in record.photos.filter(is_active=True).all():
if not photo.image:
continue
optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
photos_payload.append({
'id': photo.pk,
'image_url': request.build_absolute_uri(photo.image.url),
'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"),
'description': photo.description,
})
records_payload.append({
'id': record.pk,
'service_title': record.service_title,
'description': record.description,
'service_date': record.service_date,
'cost': str(record.cost),
'mileage': record.mileage,
'performed_by_name': _user_display_name(record.performed_by),
'vehicle_registration': getattr(record.vehicle, 'registration_number', None),
'photos': photos_payload,
})
payload.append({
'id': task.pk,
'title': task.title,
'status': task.status,
'description': task.description,
'assigned_to_name': _user_display_name(task.assigned_to),
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None),
'service_records': records_payload,
})
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()
recipient = _first_non_empty(
request.data.get('recipient'),
getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
getattr(work_order.creator, 'email', None),
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_work_order_pdf(work_order)
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga."
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=_pdf_filename(work_order, 'work_order'),
pdf_bytes=pdf_bytes,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
serializer_class = VehicleServiceRecordSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
return _service_records_queryset_for_user(user)
@action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None):
service_record = self.get_object()
pdf_bytes = _build_service_record_pdf(service_record)
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
return response
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
service_record = self.get_object()
recipient = _first_non_empty(
request.data.get('recipient'),
getattr(getattr(service_record.vehicle, 'client', None), 'email', None),
getattr(service_record.performed_by, 'email', None),
)
if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_service_record_pdf(service_record)
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
body = _first_non_empty(request.data.get('message')) or (
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
)
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=f"{service_record.pk}.service-record.pdf",
pdf_bytes=pdf_bytes,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
serializer_class = WorkOrderInvoiceSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
user = self.request.user
work_order_id = self.request.query_params.get('work_order_id')
qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
if user.is_staff:
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
allowed_orders = _work_orders_queryset_for_user(user).values('id')
qs = qs.filter(work_order_id__in=allowed_orders)
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
return context
@action(detail=True, methods=['get'], url_path='image')
def image(self, request, pk=None):
invoice = self.get_object()
if not invoice.image:
raise DRFValidationError({"detail": "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 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'])