Dodano je origin_location polje kroz backend model, serializer, admin i UI modal za kreiranje/uređivanje putnog naloga (default: Zagreb). Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza. Za greške uploada računa dodan je error toast kako bi poruke validacije bile vidljive korisniku odmah u sučelju. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
401 lines
17 KiB
Python
401 lines
17 KiB
Python
from rest_framework import serializers
|
|
import re
|
|
from pathlib import Path
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from .models import (
|
|
Crane,
|
|
Vehicle,
|
|
VehicleNotification,
|
|
WorkOrder,
|
|
WorkOrderPhoto,
|
|
WorkOrderInvoice,
|
|
VehicleServiceRecord,
|
|
VehicleServicePhoto,
|
|
VehicleServiceAttachment,
|
|
)
|
|
from . import services
|
|
|
|
|
|
class VehicleSerializer(serializers.ModelSerializer):
|
|
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
|
|
client_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Vehicle
|
|
fields = [
|
|
'id', 'asset_type', 'registration_number', 'make', 'model', 'year', 'vin',
|
|
'crane_serial_number',
|
|
'superstructure_working_hours', 'chassis_working_hours',
|
|
'current_mileage', 'service_interval_km',
|
|
'is_company_vehicle', 'client', 'client_name',
|
|
'assigned_servicer', 'is_active',
|
|
]
|
|
read_only_fields = ['id', 'current_mileage', 'client_name']
|
|
|
|
def get_client_name(self, obj):
|
|
if obj.client_id:
|
|
return obj.client.name
|
|
return None
|
|
|
|
def create(self, validated_data):
|
|
validated_data.setdefault('asset_type', 'vehicle')
|
|
return services.VehicleService.create_vehicle(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
validated_data.pop('asset_type', None)
|
|
return services.VehicleService.update_vehicle(instance, validated_data)
|
|
|
|
def validate_crane_serial_number(self, value):
|
|
if value in (None, ''):
|
|
return None
|
|
normalized = str(value).strip()
|
|
if not re.fullmatch(r"[0-9]{6}", normalized):
|
|
raise serializers.ValidationError('Serijski broj dizalice mora sadržavati točno 6 znamenki.')
|
|
return normalized
|
|
|
|
def validate(self, attrs):
|
|
asset_type = attrs.get('asset_type', getattr(self.instance, 'asset_type', None))
|
|
if asset_type is None and getattr(self.Meta, 'model', None) is Crane:
|
|
asset_type = 'crane'
|
|
serial = attrs.get('crane_serial_number', getattr(self.instance, 'crane_serial_number', None))
|
|
is_create = self.instance is None
|
|
if asset_type == 'crane' and not serial and is_create:
|
|
raise serializers.ValidationError({
|
|
'crane_serial_number': 'Serijski broj dizalice je obavezan za dizalice.'
|
|
})
|
|
return attrs
|
|
|
|
class CraneSerializer(VehicleSerializer):
|
|
display_name = serializers.SerializerMethodField()
|
|
|
|
class Meta(VehicleSerializer.Meta):
|
|
model = Crane
|
|
fields = VehicleSerializer.Meta.fields + ['display_name']
|
|
read_only_fields = VehicleSerializer.Meta.read_only_fields + ['display_name']
|
|
|
|
def get_display_name(self, obj):
|
|
details = " ".join(part for part in [obj.make, obj.model] if part)
|
|
return f"{obj.registration_number} {details}".strip()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['asset_type'] = 'crane'
|
|
return services.VehicleService.create_vehicle(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
validated_data.pop('asset_type', None)
|
|
return services.VehicleService.update_vehicle(instance, validated_data)
|
|
|
|
class VehicleNotificationSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = VehicleNotification
|
|
fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'is_read', 'created_at']
|
|
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
|
|
# is_read is writable via the "mark_read" action, but normally read-only in list
|
|
|
|
class WorkOrderSerializer(serializers.ModelSerializer):
|
|
crane = serializers.SerializerMethodField()
|
|
crane_label = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = WorkOrder
|
|
fields = [
|
|
'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
|
|
'start_mileage', 'end_mileage',
|
|
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
|
|
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
|
|
'servicer_vehicle_fuel_cost',
|
|
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
|
|
'purpose', 'notes', 'status'
|
|
]
|
|
read_only_fields = ['id', 'date']
|
|
extra_kwargs = {
|
|
'vehicle': {'required': False},
|
|
'creator': {'required': False},
|
|
'start_mileage': {'required': False, 'allow_null': True},
|
|
'end_mileage': {'required': False, 'allow_null': True},
|
|
'display_code': {'required': False, 'allow_blank': False},
|
|
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
|
|
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
|
|
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
|
|
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
|
|
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
|
|
'origin_location': {'required': False, 'allow_blank': True},
|
|
'location': {'required': False, 'allow_blank': True},
|
|
'travel_start_at': {'required': False, 'allow_null': True},
|
|
'travel_end_at': {'required': False, 'allow_null': True},
|
|
}
|
|
|
|
def get_crane(self, obj):
|
|
return obj.vehicle_id
|
|
|
|
def get_crane_label(self, obj):
|
|
if not obj.vehicle_id:
|
|
return None
|
|
details = " ".join(part for part in [obj.vehicle.registration_number, obj.vehicle.make, obj.vehicle.model] if part)
|
|
return details.strip()
|
|
|
|
def create(self, validated_data):
|
|
request = self.context.get('request')
|
|
user = getattr(request, 'user', None)
|
|
|
|
if not validated_data.get('vehicle'):
|
|
raise serializers.ValidationError({
|
|
'vehicle': 'Odaberite dizalicu za putni nalog iz servisnog konteksta.'
|
|
})
|
|
if getattr(validated_data['vehicle'], 'asset_type', None) != 'crane':
|
|
raise serializers.ValidationError({
|
|
'vehicle': 'Za putni nalog morate odabrati dizalicu (asset_type=crane).'
|
|
})
|
|
|
|
if user and 'creator' not in validated_data:
|
|
validated_data['creator'] = user
|
|
if not validated_data.get('display_code'):
|
|
creator = validated_data.get('creator')
|
|
generated_display_code = self._generate_display_code(creator, timezone.localdate())
|
|
if not generated_display_code:
|
|
raise serializers.ValidationError({
|
|
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
|
|
})
|
|
validated_data['display_code'] = generated_display_code
|
|
|
|
return services.VehicleService.create_work_order(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
return services.VehicleService.update_work_order(instance, validated_data)
|
|
|
|
def validate(self, attrs):
|
|
current = getattr(self, 'instance', None)
|
|
travel_start_at = attrs.get('travel_start_at', getattr(current, 'travel_start_at', None))
|
|
travel_end_at = attrs.get('travel_end_at', getattr(current, 'travel_end_at', None))
|
|
location = attrs.get('location', getattr(current, 'location', None))
|
|
|
|
start_mileage = attrs.get('start_mileage', getattr(current, 'start_mileage', None))
|
|
end_mileage = attrs.get('end_mileage', getattr(current, 'end_mileage', None))
|
|
servicer_start_mileage = attrs.get('servicer_vehicle_start_mileage', getattr(current, 'servicer_vehicle_start_mileage', None))
|
|
servicer_end_mileage = attrs.get('servicer_vehicle_end_mileage', getattr(current, 'servicer_vehicle_end_mileage', None))
|
|
servicer_fuel_cost = attrs.get('servicer_vehicle_fuel_cost', getattr(current, 'servicer_vehicle_fuel_cost', None))
|
|
|
|
if (travel_start_at is None) ^ (travel_end_at is None):
|
|
raise serializers.ValidationError({'travel_end_at': 'Unesite i početak i kraj puta, ili ostavite oba prazna.'})
|
|
if travel_start_at is not None and not str(location or '').strip():
|
|
raise serializers.ValidationError({'location': 'Lokacija je obavezna kada je definiran putni nalog.'})
|
|
if travel_start_at and travel_end_at and travel_end_at < travel_start_at:
|
|
raise serializers.ValidationError({'travel_end_at': 'Kraj puta ne može biti prije početka puta.'})
|
|
if end_mileage is not None and start_mileage is None:
|
|
raise serializers.ValidationError({'start_mileage': 'Početna kilometraža je obavezna ako je upisana završna.'})
|
|
if start_mileage is not None and end_mileage is not None and end_mileage < start_mileage:
|
|
raise serializers.ValidationError({'end_mileage': 'Završna kilometraža mora biti veća ili jednaka početnoj.'})
|
|
if servicer_end_mileage is not None and servicer_start_mileage is None:
|
|
raise serializers.ValidationError({'servicer_vehicle_start_mileage': 'Početna kilometraža vozila servisera je obavezna ako je upisana završna.'})
|
|
if servicer_start_mileage is not None and servicer_end_mileage is not None and servicer_end_mileage <= servicer_start_mileage:
|
|
raise serializers.ValidationError({'servicer_vehicle_end_mileage': 'Završna kilometraža vozila servisera mora biti veća od početne.'})
|
|
if servicer_fuel_cost is not None and servicer_fuel_cost < 0:
|
|
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
|
|
return attrs
|
|
|
|
def validate_display_code(self, value):
|
|
normalized = str(value or '').strip().upper()
|
|
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
|
|
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _generate_display_code(user, target_date):
|
|
if user is None:
|
|
return None
|
|
first_name = str(getattr(user, 'first_name', '') or '').strip()
|
|
last_name = str(getattr(user, 'last_name', '') or '').strip()
|
|
if not first_name or not last_name:
|
|
return None
|
|
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
|
|
|
|
|
|
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
|
image_url = serializers.SerializerMethodField()
|
|
image_content_type = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = WorkOrderInvoice
|
|
fields = [
|
|
'id',
|
|
'work_order',
|
|
'naziv_racuna',
|
|
'lokacija',
|
|
'datum',
|
|
'opis',
|
|
'image',
|
|
'image_url',
|
|
'image_content_type',
|
|
'created_by',
|
|
'created_at',
|
|
]
|
|
read_only_fields = ['id', 'created_by', 'created_at']
|
|
|
|
def get_image_url(self, obj):
|
|
request = self.context.get('request')
|
|
if not obj.image:
|
|
return None
|
|
if request is None:
|
|
return None
|
|
return request.build_absolute_uri(
|
|
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
|
)
|
|
|
|
def get_image_content_type(self, obj):
|
|
if not obj.image:
|
|
return None
|
|
suffix = Path(obj.image.name or '').suffix.lower()
|
|
if suffix == '.pdf':
|
|
return 'application/pdf'
|
|
if suffix in {'.jpg', '.jpeg'}:
|
|
return 'image/jpeg'
|
|
if suffix == '.png':
|
|
return 'image/png'
|
|
if suffix == '.webp':
|
|
return 'image/webp'
|
|
if suffix == '.gif':
|
|
return 'image/gif'
|
|
return 'application/octet-stream'
|
|
|
|
def validate_image(self, value):
|
|
if not value:
|
|
return value
|
|
suffix = Path(getattr(value, 'name', '')).suffix.lower()
|
|
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
|
|
if suffix not in allowed_suffixes:
|
|
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
|
|
return value
|
|
|
|
|
|
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()
|
|
photos_count = serializers.SerializerMethodField()
|
|
attachments_count = serializers.SerializerMethodField()
|
|
files_count = serializers.SerializerMethodField()
|
|
performed_by_name = serializers.SerializerMethodField()
|
|
vehicle_registration = serializers.SerializerMethodField()
|
|
task_label = serializers.SerializerMethodField()
|
|
crane_serial_number = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = VehicleServiceRecord
|
|
fields = [
|
|
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
|
|
'task', 'task_label', 'crane_serial_number',
|
|
'performed_by', 'performed_by_name',
|
|
'service_title', 'description', 'parts', 'cost',
|
|
'service_date', 'mileage', 'next_service_due_at',
|
|
'photos_count', 'attachments_count', 'files_count',
|
|
]
|
|
read_only_fields = [
|
|
'id', 'service_date', 'next_service_due_at',
|
|
'photos_count', 'attachments_count', 'files_count',
|
|
'performed_by_name', 'vehicle_registration', 'crane', 'crane_registration',
|
|
'task_label',
|
|
]
|
|
extra_kwargs = {
|
|
'performed_by': {'required': False},
|
|
'vehicle': {'required': False},
|
|
'mileage': {'required': False, 'allow_null': True},
|
|
}
|
|
|
|
def get_photos_count(self, obj):
|
|
# prefetch_related('photos') u viewsetu sprječava N+1
|
|
return obj.photos.count()
|
|
|
|
def get_crane(self, obj):
|
|
return obj.vehicle_id
|
|
|
|
def get_attachments_count(self, obj):
|
|
return obj.attachments.count()
|
|
|
|
def get_files_count(self, obj):
|
|
return self.get_photos_count(obj) + self.get_attachments_count(obj)
|
|
|
|
def get_performed_by_name(self, obj):
|
|
if obj.performed_by_id:
|
|
return obj.performed_by.get_full_name() or obj.performed_by.username
|
|
return None
|
|
|
|
def get_vehicle_registration(self, obj):
|
|
if obj.vehicle_id:
|
|
return obj.vehicle.registration_number
|
|
return None
|
|
|
|
def get_crane_registration(self, obj):
|
|
return self.get_vehicle_registration(obj)
|
|
|
|
def get_task_label(self, obj):
|
|
if not obj.task_id:
|
|
return None
|
|
return obj.task.title
|
|
|
|
def get_crane_serial_number(self, obj):
|
|
# Provjeri je li povezano vozilo/dizalica postoji i vrati serijski broj
|
|
if hasattr(obj, 'vehicle') and obj.vehicle:
|
|
return getattr(obj.vehicle, 'crane_serial_number', None)
|
|
return None
|
|
|
|
def create(self, validated_data):
|
|
request = self.context.get('request')
|
|
user = getattr(request, 'user', None)
|
|
task = validated_data.get('task')
|
|
if not task:
|
|
raise serializers.ValidationError({
|
|
'task': 'Odaberite radni zadatak prije kreiranja servisnog zapisa.'
|
|
})
|
|
if not task.vehicle_id:
|
|
raise serializers.ValidationError({
|
|
'task': 'Odabrani radni zadatak nema dodijeljenu dizalicu.'
|
|
})
|
|
validated_data['vehicle'] = task.vehicle
|
|
|
|
if user and not getattr(user, 'is_staff', False):
|
|
validated_data['performed_by'] = user
|
|
elif user and 'performed_by' not in validated_data:
|
|
validated_data['performed_by'] = user
|
|
return services.VehicleService.create_service_record(validated_data)
|
|
|
|
class VehicleServicePhotoSerializer(serializers.ModelSerializer):
|
|
image = serializers.ImageField(required=True)
|
|
|
|
class Meta:
|
|
model = VehicleServicePhoto
|
|
fields = ['id', 'service_record', 'image', 'description', 'uploaded_by', 'created_at']
|
|
read_only_fields = ['id', 'created_at', 'uploaded_by']
|
|
|
|
def create(self, validated_data):
|
|
# Ako postoji request user, postavi uploaded_by
|
|
request = self.context.get('request') if self.context else None
|
|
user = getattr(request, 'user', None)
|
|
if user and 'uploaded_by' not in validated_data:
|
|
validated_data['uploaded_by'] = user
|
|
# Kreiranje direktno kroz model je ok; može se delegirati u services ako želiš dodatnu logiku
|
|
return VehicleServicePhoto.objects.create(**validated_data)
|
|
|
|
class VehicleServiceAttachmentSerializer(serializers.ModelSerializer):
|
|
file = serializers.FileField(required=True)
|
|
|
|
class Meta:
|
|
model = VehicleServiceAttachment
|
|
fields = ['id', 'service_record', 'file', 'description', 'uploaded_by', 'created_at']
|
|
read_only_fields = ['id', 'created_at', 'uploaded_by']
|
|
|
|
def create(self, validated_data):
|
|
request = self.context.get('request') if self.context else None
|
|
user = getattr(request, 'user', None)
|
|
if user and 'uploaded_by' not in validated_data:
|
|
validated_data['uploaded_by'] = user
|
|
return VehicleServiceAttachment.objects.create(**validated_data) |