diff --git a/backend/core/users/import_templates/users_import_template.csv b/backend/core/users/import_templates/users_import_template.csv
index 4f803a2..1caa8a3 100644
--- a/backend/core/users/import_templates/users_import_template.csv
+++ b/backend/core/users/import_templates/users_import_template.csv
@@ -4,3 +4,5 @@ serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
+ramo@knezljubo.hr,Ramo,Husanović,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
+domagoj@knezljubo.hr,Domagoj,Knez,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
\ No newline at end of file
diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py
index 8bb9864..7d5b4e1 100644
--- a/backend/modules/fleet/admin.py
+++ b/backend/modules/fleet/admin.py
@@ -5,6 +5,7 @@ from .models import (
Crane,
Vehicle,
WorkOrder,
+ WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -60,6 +61,26 @@ class WorkOrderInvoiceInline(admin.TabularInline):
readonly_fields = ('created_at',)
+class WorkOrderPhotoInline(admin.TabularInline):
+ model = WorkOrderPhoto
+ extra = 0
+ fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at', 'is_active')
+ readonly_fields = ('thumbnail_preview', 'created_at')
+ show_change_link = True
+
+ @admin.display(description="Pregled")
+ def thumbnail_preview(self, obj):
+ if obj.image:
+ return format_html(
+ ''
+ ''
+ '',
+ obj.image.url,
+ obj.image.url,
+ )
+ return "—"
+
+
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = (
@@ -159,7 +180,7 @@ class WorkOrderAdmin(admin.ModelAdmin):
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
- inlines = (WorkOrderInvoiceInline,)
+ inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
def distance_display(self, obj):
return obj.distance
diff --git a/backend/modules/fleet/migrations/0022_workorderphoto.py b/backend/modules/fleet/migrations/0022_workorderphoto.py
new file mode 100644
index 0000000..2345915
--- /dev/null
+++ b/backend/modules/fleet/migrations/0022_workorderphoto.py
@@ -0,0 +1,43 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+def work_order_photo_upload_to(instance, filename):
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.work_order.vehicle.registration_number
+ except Exception:
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0021_alter_vehicle_assigned_servicer_many_to_one'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='WorkOrderPhoto',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('image', models.ImageField(upload_to=work_order_photo_upload_to, verbose_name='Fotografija putnog naloga')),
+ ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
+ ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_work_order_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
+ ('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.workorder', verbose_name='Putni nalog')),
+ ],
+ options={
+ 'verbose_name': 'Fotografija putnog naloga',
+ 'verbose_name_plural': 'Fotografije putnog naloga',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py
index 0bae765..1541680 100644
--- a/backend/modules/fleet/models.py
+++ b/backend/modules/fleet/models.py
@@ -45,6 +45,15 @@ def _work_order_invoice_upload_to(instance, filename):
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
+def _work_order_photo_upload_to(instance, filename):
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.work_order.vehicle.registration_number
+ except Exception:
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
+
class VehicleServicePhoto(BaseModel):
"""
@@ -344,6 +353,37 @@ class WorkOrderInvoice(BaseModel):
return f"{self.naziv_racuna} ({self.work_order_id})"
+class WorkOrderPhoto(BaseModel):
+ work_order = models.ForeignKey(
+ WorkOrder,
+ on_delete=models.CASCADE,
+ related_name='photos',
+ verbose_name=_("Putni nalog"),
+ )
+ image = models.ImageField(
+ upload_to=_work_order_photo_upload_to,
+ verbose_name=_("Fotografija putnog naloga"),
+ )
+ description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
+ uploaded_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='uploaded_work_order_photos',
+ verbose_name=_("Postavio"),
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
+
+ class Meta:
+ verbose_name = _("Fotografija putnog naloga")
+ verbose_name_plural = _("Fotografije putnog naloga")
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
+
+
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.
diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py
index a068f65..8915e11 100644
--- a/backend/modules/fleet/serializers.py
+++ b/backend/modules/fleet/serializers.py
@@ -5,6 +5,7 @@ from .models import (
Vehicle,
VehicleNotification,
WorkOrder,
+ WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -198,6 +199,16 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'created_by', 'created_at']
+class WorkOrderPhotoSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = WorkOrderPhoto
+ fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
+ read_only_fields = ['id', 'uploaded_by', 'created_at']
+ extra_kwargs = {
+ 'work_order': {'required': False},
+ }
+
+
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_registration = serializers.SerializerMethodField()
diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py
new file mode 100644
index 0000000..9fadd85
--- /dev/null
+++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py
@@ -0,0 +1,96 @@
+from django.contrib.auth import get_user_model
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import TestCase
+from django.urls import reverse
+from rest_framework.test import APIClient
+import uuid
+from io import BytesIO
+from PIL import Image
+
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleServicePhoto
+
+
+def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
+ file = BytesIO()
+ image = Image.new('RGB', size=size, color=color)
+ image.save(file, 'JPEG')
+ file.seek(0)
+ return SimpleUploadedFile(
+ name=filename,
+ content=file.getvalue(),
+ content_type='image/jpeg',
+ )
+
+
+class WorkOrderImagesEndpointTests(TestCase):
+ def setUp(self):
+ user_model = get_user_model()
+ suffix = uuid.uuid4().hex[:8]
+ self.user = user_model.objects.create_user(
+ username=f'wo-img-user-{suffix}',
+ email=f'wo-img-user-{suffix}@example.test',
+ password='test1234',
+ )
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.user)
+
+ self.vehicle = Vehicle.objects.create(
+ asset_type='crane',
+ registration_number='WO-IMG-001',
+ crane_serial_number='123456',
+ )
+ self.work_order = WorkOrder.objects.create(
+ vehicle=self.vehicle,
+ creator=self.user,
+ purpose='kontrola',
+ )
+
+ self.service_record = VehicleServiceRecord.objects.create(
+ vehicle=self.vehicle,
+ performed_by=self.user,
+ description='Servisni zapis za test izolacije fotografija.',
+ service_title='Test servis',
+ )
+
+ def test_work_order_images_endpoint_isolated_from_service_record_photos(self):
+ # Existing service photo must NOT appear in work-order images response
+ VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=create_test_image('service.jpg'),
+ description='Service photo',
+ uploaded_by=self.user,
+ )
+
+ upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
+ response = self.client.post(
+ upload_url,
+ data={
+ 'image': create_test_image('workorder.jpg'),
+ 'description': 'Work order photo',
+ },
+ format='multipart',
+ )
+ self.assertEqual(response.status_code, 201, response.data)
+ self.assertEqual(str(response.data.get('work_order')), str(self.work_order.pk))
+
+ list_response = self.client.get(upload_url)
+ self.assertEqual(list_response.status_code, 200)
+ images = list_response.data.get('images', [])
+ self.assertEqual(len(images), 1)
+ self.assertEqual(str(images[0].get('work_order_id')), str(self.work_order.pk))
+
+ def test_service_record_files_endpoint_does_not_return_work_order_images(self):
+ upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
+ self.client.post(
+ upload_url,
+ data={
+ 'image': create_test_image('workorder-2.jpg'),
+ 'description': 'WO isolated',
+ },
+ format='multipart',
+ )
+
+ service_photos_url = f"{reverse('service-photo-list')}?service_record_id={self.service_record.pk}"
+ response = self.client.get(service_photos_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.data), 0)
diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py
index 30867a2..ffe974c 100644
--- a/backend/modules/fleet/views.py
+++ b/backend/modules/fleet/views.py
@@ -30,6 +30,7 @@ from .models import (
Vehicle,
VehicleNotification,
WorkOrder,
+ WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -40,6 +41,7 @@ from .serializers import (
VehicleSerializer,
WorkOrderSerializer,
WorkOrderInvoiceSerializer,
+ WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer,
VehicleNotificationSerializer,
VehicleServicePhotoSerializer,
@@ -1240,10 +1242,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return _work_orders_queryset_for_user(self.request.user)
- @action(detail=True, methods=['get'], url_path='images')
+ @action(detail=True, methods=['get', 'post'], url_path='images', parser_classes=[MultiPartParser, FormParser])
def images(self, request, pk=None):
work_order = self.get_object()
- service_record_id = request.query_params.get('service_record_id')
+ 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',
@@ -1259,60 +1266,29 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
max_value=3840,
)
image_format, _ = _parse_format(request.query_params.get('fmt'))
- work_order_task_ids = list(
- work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
- )
-
- if not work_order_task_ids:
- return Response({
- "work_order_id": work_order.pk,
- "vehicle_id": work_order.vehicle_id,
- "service_record_id": service_record_id or None,
- "images": [],
- }, status=status.HTTP_200_OK)
-
- photos_qs = VehicleServicePhoto.objects.filter(
+ photos_qs = WorkOrderPhoto.objects.filter(
is_active=True,
- service_record__is_active=True,
- service_record__vehicle_id=work_order.vehicle_id,
- service_record__task_id__in=work_order_task_ids,
- ).select_related('service_record', 'uploaded_by').order_by('-created_at')
-
- if not request.user.is_staff:
- photos_qs = photos_qs.filter(service_record__performed_by=request.user)
-
- if service_record_id:
- record = _service_records_queryset_for_user(request.user).filter(
- pk=service_record_id,
- vehicle_id=work_order.vehicle_id,
- task_id__in=work_order_task_ids,
- ).first()
- if record is None:
- raise DRFValidationError({
- "service_record_id": "Servisni zapis nije pronađen ili ne pripada ovom nalogu."
- })
- photos_qs = photos_qs.filter(service_record_id=record.pk)
+ work_order_id=work_order.pk,
+ ).select_related('uploaded_by').order_by('-created_at')
images = []
for photo in photos_qs:
- optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
- optimized_url = request.build_absolute_uri(
- f"{optimized_path}?w={width}&q={quality}&fmt={image_format.lower()}"
- )
original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
images.append({
"id": photo.pk,
- "service_record_id": photo.service_record_id,
+ "work_order_id": photo.work_order_id,
"description": photo.description,
"original_url": original_url,
- "optimized_url": optimized_url,
+ "optimized_url": original_url,
"created_at": photo.created_at,
})
return Response({
"work_order_id": work_order.pk,
"vehicle_id": work_order.vehicle_id,
- "service_record_id": service_record_id or None,
+ "image_quality": quality,
+ "image_width": width,
+ "image_format": image_format.lower(),
"images": images,
}, status=status.HTTP_200_OK)
diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx
index bf17be7..2003c61 100644
--- a/frontend/src/components/dashboard/FleetDashboardShell.jsx
+++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx
@@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal';
import SyncLogPanel from './SyncLogPanel';
import ServiceRecordForm from '../fleet/ServiceRecordForm';
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
+import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
import ClientsSection from '../crm/ClientsSection';
@@ -93,6 +94,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
// photoUpload: { open: bool, serviceRecordId: string|null }
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
+ const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
const loading = useStore($dashboardLoading);
const error = useStore($dashboardError);
const stats = useStore($dashboardStats);
@@ -492,6 +494,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const workOrderColumns = [
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
+ { key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
{ key: 'creator', label: 'Creator', className: 'px-4 py-3' },
@@ -756,6 +759,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
>
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
+
+ Putni nalog #{workOrderId} +
+