feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-16 06:33:08 +02:00
parent 1ab2bfb3c3
commit 8e4b3f8aea
11 changed files with 539 additions and 58 deletions

View File

@@ -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(
'<a href="{}" target="_blank">'
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
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

View File

@@ -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'],
},
),
]

View File

@@ -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.

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)