Dodaje status 'spreman_za_zavrsetak' i backend provjeru koja blokira zatvaranje taska kada je povezani putni nalog otvoren, uz 409 odgovor i opcionalni auto-close flow.\n\nPri zatvaranju putnog naloga taskovi u statusu 'servis' automatski prelaze u 'spreman_za_zavrsetak'. Frontend modal sada podržava potvrdu i retry s auto_close_work_order=true.\n\nUključeni su i ciljani testovi za novi behavior, te ažuriran prikaz statusa u dashboardu.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
422 lines
18 KiB
Python
422 lines
18 KiB
Python
from django.db import transaction
|
|
from django.conf import settings
|
|
from django.contrib.auth import get_user_model
|
|
from rest_framework.exceptions import ValidationError
|
|
from modules.fleet.tasks import send_email_task
|
|
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification, VehicleServicePhoto
|
|
from infrastructure.pusher_service import PusherService
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
User = get_user_model()
|
|
|
|
class NotificationService:
|
|
@staticmethod
|
|
def create_notification(recipient=None, title="", message="", level="info", send_email=False, metadata=None):
|
|
# 1. Kreiraj notifikaciju u DB-u
|
|
notif = VehicleNotification.objects.create(
|
|
recipient=recipient,
|
|
title=title[:255],
|
|
message=message,
|
|
metadata=metadata or {},
|
|
level=level,
|
|
is_sent=False
|
|
)
|
|
|
|
# 2. Broadcast preko WebSocket (Pusher) tek nakon uspješnog commit-a
|
|
if recipient:
|
|
transaction.on_commit(lambda: PusherService.broadcast_notification(
|
|
user_id=recipient.id,
|
|
title=title,
|
|
message=message,
|
|
level=level,
|
|
notification_id=notif.id,
|
|
metadata=metadata
|
|
))
|
|
|
|
# 3. Slanje emaila ako je traženo
|
|
if send_email and recipient and getattr(recipient, 'email', None):
|
|
email_args = {
|
|
'recipient_email': recipient.email,
|
|
'subject': title,
|
|
'message': message,
|
|
'notif_id': notif.id
|
|
}
|
|
|
|
if settings.DEBUG or getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
|
|
send_email_task(**email_args)
|
|
else:
|
|
transaction.on_commit(lambda: send_email_task.delay(**email_args))
|
|
|
|
return notif
|
|
class VehicleService:
|
|
@staticmethod
|
|
def create_vehicle(data: dict) -> Vehicle:
|
|
vehicle = Vehicle(**data)
|
|
try:
|
|
vehicle.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
|
|
vehicle.save()
|
|
return vehicle
|
|
|
|
@staticmethod
|
|
def update_vehicle(instance: Vehicle, data: dict) -> Vehicle:
|
|
for k, v in data.items():
|
|
setattr(instance, k, v)
|
|
try:
|
|
instance.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
instance.save()
|
|
return instance
|
|
|
|
@staticmethod
|
|
def create_work_order(data: dict) -> WorkOrder:
|
|
# expecting creator in data
|
|
wo = WorkOrder(**data)
|
|
try:
|
|
wo.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
|
|
with transaction.atomic():
|
|
wo.save()
|
|
|
|
# obavijesti creatora i serviser-a vozila
|
|
try:
|
|
# Notify the creator
|
|
NotificationService.create_notification(
|
|
recipient=wo.creator,
|
|
title=f"Putni nalog kreiran: WO-{wo.pk}",
|
|
message=f"Putni nalog za dizalicu {wo.vehicle.registration_number} je kreiran.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(wo.pk),
|
|
"vehicle_id": str(wo.vehicle_id),
|
|
"crane_id": str(wo.vehicle_id),
|
|
"section": "work-orders",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
# Notify assigned servicer if present
|
|
servicer = wo.vehicle.assigned_servicer
|
|
if servicer:
|
|
NotificationService.create_notification(
|
|
recipient=servicer,
|
|
title=f"Novi putni nalog za {wo.vehicle.registration_number}",
|
|
message=f"Imate novi putni nalog (WO-{wo.pk}) za dizalicu {wo.vehicle.registration_number}.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(wo.pk),
|
|
"vehicle_id": str(wo.vehicle_id),
|
|
"crane_id": str(wo.vehicle_id),
|
|
"section": "work-orders",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
except Exception:
|
|
logger.exception("Greška pri slanju notifikacija za create_work_order")
|
|
|
|
return wo
|
|
|
|
@staticmethod
|
|
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
|
|
previous_status = instance.status
|
|
for k, v in data.items():
|
|
setattr(instance, k, v)
|
|
try:
|
|
instance.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
with transaction.atomic():
|
|
instance.save()
|
|
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
|
|
if transitioned_to_closed:
|
|
from modules.task_management.models import Task
|
|
Task.objects.filter(
|
|
is_active=True,
|
|
work_order=instance,
|
|
status='servis',
|
|
).update(status='spreman_za_zavrsetak')
|
|
|
|
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
|
|
if transitioned_to_closed:
|
|
try:
|
|
NotificationService.create_notification(
|
|
recipient=instance.creator,
|
|
title=f"Putni nalog zatvoren: WO-{instance.pk}",
|
|
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je zatvoren.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(instance.pk),
|
|
"vehicle_id": str(instance.vehicle_id),
|
|
"crane_id": str(instance.vehicle_id),
|
|
"section": "work-orders",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
servicer = instance.vehicle.assigned_servicer
|
|
if servicer:
|
|
NotificationService.create_notification(
|
|
recipient=servicer,
|
|
title=f"Putni nalog zatvoren za {instance.vehicle.registration_number}",
|
|
message=f"Putni nalog (WO-{instance.pk}) je zatvoren.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(instance.pk),
|
|
"vehicle_id": str(instance.vehicle_id),
|
|
"crane_id": str(instance.vehicle_id),
|
|
"section": "work-orders",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
except Exception:
|
|
logger.exception("Greška pri slanju notifikacija za update_work_order")
|
|
return instance
|
|
|
|
@staticmethod
|
|
def delete_work_order(instance: WorkOrder, user=None) -> WorkOrder:
|
|
"""
|
|
Soft-delete putnog naloga. Samo creator ili staff može obrisati.
|
|
Vraća deaktivirani instance.
|
|
"""
|
|
# Provjera prava: samo creator ili staff
|
|
if not user:
|
|
raise ValidationError("Korisnik nije naveden za operaciju brisanja.")
|
|
if not (user.is_staff or instance.creator == user):
|
|
raise ValidationError("Nemate dozvolu za brisanje ovog putnog naloga.")
|
|
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active'])
|
|
|
|
# Notifikacija o brisanju
|
|
try:
|
|
NotificationService.create_notification(
|
|
recipient=instance.creator,
|
|
title=f"Putni nalog obrisan: WO-{instance.pk}",
|
|
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je obrisan od strane {user}.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(instance.pk),
|
|
"vehicle_id": str(instance.vehicle_id),
|
|
"crane_id": str(instance.vehicle_id),
|
|
"section": "work-orders",
|
|
"deleted": True,
|
|
},
|
|
level="warning",
|
|
send_email=False
|
|
)
|
|
servicer = instance.vehicle.assigned_servicer
|
|
if servicer:
|
|
NotificationService.create_notification(
|
|
recipient=servicer,
|
|
title=f"Putni nalog obrisan za {instance.vehicle.registration_number}",
|
|
message=f"Putni nalog (WO-{instance.pk}) je obrisan.",
|
|
metadata={
|
|
"entity_type": "work_order",
|
|
"work_order_id": str(instance.pk),
|
|
"vehicle_id": str(instance.vehicle_id),
|
|
"crane_id": str(instance.vehicle_id),
|
|
"section": "work-orders",
|
|
"deleted": True,
|
|
},
|
|
level="warning",
|
|
send_email=False
|
|
)
|
|
except Exception:
|
|
logger.exception("Greška pri slanju notifikacija za delete_work_order")
|
|
|
|
logger.info(f"WorkOrder {instance.pk} soft-deleted by {user}")
|
|
return instance
|
|
|
|
@staticmethod
|
|
def create_service_record(data: dict) -> VehicleServiceRecord:
|
|
rec = VehicleServiceRecord(**data)
|
|
try:
|
|
rec.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
with transaction.atomic():
|
|
rec.save()
|
|
# update vehicle mileage if needed
|
|
v = rec.vehicle
|
|
if rec.mileage and rec.mileage > (v.current_mileage or 0):
|
|
v.current_mileage = rec.mileage
|
|
v.save()
|
|
|
|
# notify assigned servicer / creator
|
|
try:
|
|
# Notify performed_by user
|
|
NotificationService.create_notification(
|
|
recipient=rec.performed_by,
|
|
title=f"Servis zabilježen: {rec.vehicle.registration_number}",
|
|
message=(
|
|
f"Zabilježen servis na dizalici {rec.vehicle.registration_number}"
|
|
+ (f" pri kilometraži {rec.mileage}." if rec.mileage is not None else ".")
|
|
),
|
|
metadata={
|
|
"entity_type": "service_record",
|
|
"service_record_id": str(rec.pk),
|
|
"vehicle_id": str(rec.vehicle_id),
|
|
"crane_id": str(rec.vehicle_id),
|
|
"section": "service-records",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
# Notify vehicle assigned servicer if different
|
|
servicer = rec.vehicle.assigned_servicer
|
|
if servicer and servicer != rec.performed_by:
|
|
NotificationService.create_notification(
|
|
recipient=servicer,
|
|
title=f"Servis na vašem vozilu {rec.vehicle.registration_number}",
|
|
message=f"Servis je izvršen na dizalici {rec.vehicle.registration_number}.",
|
|
metadata={
|
|
"entity_type": "service_record",
|
|
"service_record_id": str(rec.pk),
|
|
"vehicle_id": str(rec.vehicle_id),
|
|
"crane_id": str(rec.vehicle_id),
|
|
"section": "service-records",
|
|
},
|
|
level="info",
|
|
send_email=False
|
|
)
|
|
# If next service is soon (e.g., within 1000 km), create a warning notification
|
|
next_due = rec.next_service_due_at
|
|
if next_due is None and rec.mileage is not None:
|
|
next_due = rec.mileage + rec.vehicle.service_interval_km
|
|
if next_due is not None and rec.mileage is not None and (next_due - rec.mileage) <= 1000:
|
|
if servicer:
|
|
NotificationService.create_notification(
|
|
recipient=servicer,
|
|
title=f"Uskoro servis za {rec.vehicle.registration_number}",
|
|
message=f"Sljedeći servis za dizalicu {rec.vehicle.registration_number} očekuje se na ~{next_due} km.",
|
|
metadata={
|
|
"entity_type": "service_record",
|
|
"service_record_id": str(rec.pk),
|
|
"vehicle_id": str(rec.vehicle_id),
|
|
"crane_id": str(rec.vehicle_id),
|
|
"section": "service-records",
|
|
"next_service_due_at": next_due,
|
|
},
|
|
level="warning",
|
|
send_email=False
|
|
)
|
|
except Exception:
|
|
logger.exception("Greška pri slanju notifikacija za create_service_record")
|
|
return rec
|
|
|
|
class VehicleServicePhotoService:
|
|
"""
|
|
Servisna klasa za upravljanje VehicleServicePhoto zapisima.
|
|
Delegira upload, ažuriranje i brisanje s validacijom i sigurnosnim provjerama.
|
|
"""
|
|
|
|
@staticmethod
|
|
def upload_photo(data: dict, uploaded_by=None) -> VehicleServicePhoto:
|
|
"""
|
|
Kreiraj VehicleServicePhoto s file upload-om.
|
|
data treba sadržavati: service_record (ID ili instancu), image (UploadedFile), description (opcionalno).
|
|
"""
|
|
if not uploaded_by:
|
|
raise ValidationError("Korisnik je obavezan za upload fotografije.")
|
|
|
|
# Ako je service_record ID ili UUID, dohvati instancu
|
|
service_record = data.get('service_record')
|
|
|
|
# Provjeri je li već VehicleServiceRecord instanca
|
|
if not isinstance(service_record, VehicleServiceRecord):
|
|
# Pokušaj dohvatiti po ID-u (int, str, UUID)
|
|
try:
|
|
service_record = VehicleServiceRecord.objects.get(pk=service_record)
|
|
except (VehicleServiceRecord.DoesNotExist, ValueError, TypeError):
|
|
raise ValidationError("Servisni zapis ne postoji ili ID nije valjan.")
|
|
|
|
# Kreiraj novi dict s istim podacima ali sigurno postavljenim service_record
|
|
photo_data = {
|
|
'service_record': service_record,
|
|
'image': data.get('image'),
|
|
'description': data.get('description', ''),
|
|
'uploaded_by': uploaded_by
|
|
}
|
|
|
|
photo = VehicleServicePhoto(**photo_data)
|
|
try:
|
|
photo.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
|
|
photo.save()
|
|
logger.info(f"Fotografija uploadana od strane {uploaded_by} za servisni zapis {service_record.id}")
|
|
return photo
|
|
|
|
@staticmethod
|
|
def update_photo(instance: VehicleServicePhoto, data: dict, user=None) -> VehicleServicePhoto:
|
|
"""
|
|
Ažuriraj VehicleServicePhoto. Samo uploader ili staff mogu ažurirati.
|
|
"""
|
|
# Sigurnosna provjera: samo uploader ili staff mogu ažurirati
|
|
if not (user and (user.is_staff or instance.uploaded_by == user)):
|
|
raise ValidationError("Nemate dozvolu za ažuriranje ove fotografije.")
|
|
|
|
for attr, value in data.items():
|
|
if attr != 'image': # ne dozvoli ponovno upload slike (opciono)
|
|
setattr(instance, attr, value)
|
|
|
|
try:
|
|
instance.full_clean()
|
|
except Exception as e:
|
|
raise ValidationError({"detail": str(e)})
|
|
|
|
instance.save()
|
|
logger.info(f"Fotografija {instance.id} ažurirana od strane {user}")
|
|
return instance
|
|
|
|
@staticmethod
|
|
def delete_photo(instance: VehicleServicePhoto, user=None) -> VehicleServicePhoto:
|
|
"""
|
|
Soft-delete VehicleServicePhoto. Samo uploader ili staff mogu obrisati.
|
|
"""
|
|
if not (user and (user.is_staff or instance.uploaded_by == user)):
|
|
raise ValidationError("Nemate dozvolu za brisanje ove fotografije.")
|
|
|
|
instance.is_active = False
|
|
instance.save(update_fields=['is_active'])
|
|
logger.info(f"Fotografija {instance.id} soft-deleted od strane {user}")
|
|
return instance
|
|
|
|
class FleetService:
|
|
@staticmethod
|
|
def bulk_close_work_orders(work_orders_qs):
|
|
updated_vehicles = {}
|
|
|
|
with transaction.atomic():
|
|
for wo in work_orders_qs:
|
|
# 1. Preskoči ako je već zatvoren
|
|
if wo.status == 'closed':
|
|
continue
|
|
|
|
# 2. NOVO: Preskoči ako nalog nije kompletan (nema kilometražu)
|
|
if wo.end_mileage is None:
|
|
continue
|
|
|
|
# 3. Zatvori nalog
|
|
wo.status = 'closed'
|
|
wo.save()
|
|
|
|
# 4. Ažuriraj vozilo
|
|
vehicle = wo.vehicle
|
|
current_max = updated_vehicles.get(vehicle.pk, vehicle.current_mileage)
|
|
if wo.end_mileage > current_max:
|
|
updated_vehicles[vehicle.pk] = wo.end_mileage
|
|
|
|
# Masovno ažuriranje vozila
|
|
for v_id, new_mileage in updated_vehicles.items():
|
|
Vehicle.objects.filter(pk=v_id).update(current_mileage=new_mileage) |