from rest_framework import serializers import re from pathlib import Path from decimal import Decimal, InvalidOperation from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from modules.task_management.models import Task from .models import ( Crane, Vehicle, ServiceContextNote, MonthlyServicerDayEntry, VehicleNotification, WorkOrder, WorkOrderAdditionalCostsTable, WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, ) from . import services User = get_user_model() 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', '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 ServiceContextNoteSerializer(serializers.ModelSerializer): created_by_name = serializers.SerializerMethodField() recipient_name = serializers.SerializerMethodField() work_order_label = serializers.SerializerMethodField() task_title = serializers.SerializerMethodField() class Meta: model = ServiceContextNote fields = [ 'id', 'created_by', 'created_by_name', 'recipient', 'recipient_name', 'audience_source', 'work_order', 'work_order_label', 'task', 'task_title', 'note', 'note_date', 'is_closed', 'closed_at', 'created_at', 'updated_at', ] read_only_fields = fields def get_created_by_name(self, obj): return obj.created_by.get_full_name() or obj.created_by.email def get_recipient_name(self, obj): return obj.recipient.get_full_name() or obj.recipient.email def get_work_order_label(self, obj): if not obj.work_order_id: return None display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper() return display_code or f"WO-{obj.work_order_id}" def get_task_title(self, obj): if not obj.task_id: return None return obj.task.title class ServiceContextNoteCreateSerializer(serializers.Serializer): AUDIENCE_CHOICES = ['self', 'member', 'team'] note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) note_date = serializers.DateField(required=False, allow_null=True) audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self') target_user = serializers.PrimaryKeyRelatedField( queryset=User.objects.filter(is_active=True), required=False, allow_null=True, ) target_users = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)), required=False, allow_empty=False, ) work_order = serializers.PrimaryKeyRelatedField( queryset=WorkOrder.objects.filter(is_active=True), required=False, allow_null=True, ) task = serializers.PrimaryKeyRelatedField( queryset=Task.objects.filter(is_active=True), required=False, allow_null=True, ) def validate(self, attrs): request = self.context.get('request') user = getattr(request, 'user', None) if user is None: raise serializers.ValidationError('Autentikacija je obavezna.') audience = attrs.get('audience', 'self') target_user = attrs.get('target_user') target_users = attrs.get('target_users') or [] work_order = attrs.get('work_order') task = attrs.get('task') note = str(attrs.get('note', '') or '').strip() can_manage_recipients = bool( getattr(user, 'is_serviser', False) or getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False)) ) if not note: raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'}) if not can_manage_recipients and audience != 'self': raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'}) if audience == 'member' and not target_user and len(target_users) == 0: raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'}) if audience in {'self', 'team'} and target_user: raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'}) if audience in {'self', 'team'} and len(target_users) > 0: raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'}) if target_user and not getattr(target_user, 'is_serviser', False): raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'}) for selected_user in target_users: if not getattr(selected_user, 'is_serviser', False): raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'}) if work_order: if not work_order.is_active or str(work_order.status or '').lower() != 'open': raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'}) if task: status = str(task.status or '').lower() if status in {'zavrsen', 'neaktivan'}: raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'}) if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id): raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'}) attrs['note'] = note if task and not work_order and task.work_order_id: attrs['work_order'] = task.work_order if audience == 'member' and len(target_users) == 0 and target_user: attrs['target_users'] = [target_user] return attrs class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer): class Meta: model = MonthlyServicerDayEntry fields = [ 'id', 'entry_date', 'entry_type', 'description', 'location', 'start_time', 'end_time', 'regular_hours', 'overtime_hours', 'created_at', 'updated_at', ] read_only_fields = [ 'id', 'description', 'location', 'start_time', 'end_time', 'regular_hours', 'overtime_hours', 'created_at', 'updated_at', ] 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 base = request.build_absolute_uri( reverse('work-order-invoice-image', kwargs={'pk': obj.pk}) ) suffix = Path(obj.image.name or '').suffix.lower() if suffix == '.pdf': return base return f"{base}?w=1280&q=80&fmt=jpeg" 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 WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer): class Meta: model = WorkOrderAdditionalCostsTable fields = ['id', 'work_order', 'data', 'total_for_payout', 'created_at', 'updated_at'] read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at'] @staticmethod def _parse_decimal(value): if value in (None, ''): return Decimal('0.00') try: normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.') return Decimal(normalized) except (InvalidOperation, ValueError, TypeError): return Decimal('0.00') def _normalize_row(self, row): return { 'naziv': str(row.get('naziv', '') or '').strip(), 'broj_racuna': str(row.get('broj_racuna', '') or '').strip(), 'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(), 'prilog': str(row.get('prilog', '') or '').strip(), 'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(), } def validate_data(self, value): if value is None: return {'rows': []} if not isinstance(value, dict): raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.") rows = value.get('rows', []) if not isinstance(rows, list): raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."}) normalized_rows = [] for row in rows[:100]: if not isinstance(row, dict): raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."}) normalized_rows.append(self._normalize_row(row)) return {'rows': normalized_rows} def _calculate_total_for_payout(self, data): rows = data.get('rows', []) if isinstance(data, dict) else [] total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00')) return total.quantize(Decimal('0.01')) def create(self, validated_data): validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data')) return super().create(validated_data) def update(self, instance, validated_data): data = validated_data.get('data', instance.data or {'rows': []}) validated_data['total_for_payout'] = self._calculate_total_for_payout(data) return super().update(instance, validated_data) 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)