feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
- 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:
@@ -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
|
||||
|
||||
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal file
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user