This commit is contained in:
0
backend/modules/fleet/__init__.py
Normal file
0
backend/modules/fleet/__init__.py
Normal file
197
backend/modules/fleet/admin.py
Normal file
197
backend/modules/fleet/admin.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.utils.html import format_html
|
||||
from .models import (
|
||||
Crane,
|
||||
Vehicle,
|
||||
WorkOrder,
|
||||
WorkOrderInvoice,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
VehicleServiceAttachment,
|
||||
)
|
||||
|
||||
|
||||
class ServicePhotoInline(admin.TabularInline):
|
||||
model = VehicleServicePhoto
|
||||
extra = 0
|
||||
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at')
|
||||
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 "—"
|
||||
|
||||
class ServiceAttachmentInline(admin.TabularInline):
|
||||
model = VehicleServiceAttachment
|
||||
extra = 0
|
||||
fields = ('file', 'description', 'uploaded_by', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class WorkOrderInline(admin.TabularInline):
|
||||
model = WorkOrder
|
||||
extra = 0
|
||||
fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
|
||||
readonly_fields = ('date',)
|
||||
|
||||
|
||||
class ServiceRecordInline(admin.TabularInline):
|
||||
model = VehicleServiceRecord
|
||||
extra = 0
|
||||
fields = ('task', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost')
|
||||
readonly_fields = ('service_date', 'next_service_due_at')
|
||||
|
||||
|
||||
class WorkOrderInvoiceInline(admin.TabularInline):
|
||||
model = WorkOrderInvoice
|
||||
extra = 0
|
||||
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
|
||||
@admin.register(Vehicle)
|
||||
class VehicleAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'registration_number',
|
||||
'crane_serial_number',
|
||||
'superstructure_working_hours',
|
||||
'chassis_working_hours',
|
||||
'make',
|
||||
'model',
|
||||
'year',
|
||||
'current_mileage',
|
||||
'service_interval_km',
|
||||
'next_service_at_display',
|
||||
'is_company_vehicle',
|
||||
'client',
|
||||
'assigned_servicer',
|
||||
'is_active',
|
||||
)
|
||||
list_filter = ('is_active', 'is_company_vehicle', 'make', 'service_interval_km')
|
||||
search_fields = ('registration_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
|
||||
readonly_fields = ()
|
||||
inlines = (WorkOrderInline, ServiceRecordInline)
|
||||
ordering = ('registration_number',)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('asset_type', 'registration_number', 'vin', 'make', 'model', 'year'),
|
||||
}),
|
||||
('Vlasništvo', {
|
||||
'fields': ('is_company_vehicle', 'client', 'assigned_servicer'),
|
||||
}),
|
||||
('Servis', {
|
||||
'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
|
||||
}),
|
||||
)
|
||||
|
||||
def next_service_at_display(self, obj):
|
||||
try:
|
||||
return obj.next_service_at()
|
||||
except Exception:
|
||||
return None
|
||||
next_service_at_display.short_description = "Sljedeći servis @ (km)"
|
||||
next_service_at_display.admin_order_field = 'current_mileage'
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
return queryset.filter(asset_type='vehicle')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.asset_type = 'vehicle'
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(Crane)
|
||||
class CraneAdmin(VehicleAdmin):
|
||||
list_display = (
|
||||
'registration_number',
|
||||
'crane_serial_number',
|
||||
'superstructure_working_hours',
|
||||
'chassis_working_hours',
|
||||
'make',
|
||||
'model',
|
||||
'year',
|
||||
'current_mileage',
|
||||
'service_interval_km',
|
||||
'next_service_at_display',
|
||||
'client',
|
||||
'assigned_servicer',
|
||||
'is_active',
|
||||
)
|
||||
list_filter = ('is_active', 'client', 'make', 'service_interval_km', 'crane_serial_number')
|
||||
search_fields = ('registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('asset_type', 'registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'year'),
|
||||
}),
|
||||
('Povezivanje', {
|
||||
'fields': ('client', 'assigned_servicer', 'is_company_vehicle'),
|
||||
}),
|
||||
('Servis', {
|
||||
'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
|
||||
}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = ModelAdmin.get_queryset(self, request)
|
||||
return queryset.filter(asset_type='crane')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.asset_type = 'crane'
|
||||
ModelAdmin.save_model(self, request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(WorkOrder)
|
||||
class WorkOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
|
||||
list_filter = ('status', 'date', 'vehicle')
|
||||
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
|
||||
readonly_fields = ('date',)
|
||||
ordering = ('-date',)
|
||||
inlines = (WorkOrderInvoiceInline,)
|
||||
|
||||
def distance_display(self, obj):
|
||||
return obj.distance
|
||||
distance_display.short_description = "Prijeđeno (km)"
|
||||
|
||||
|
||||
@admin.register(WorkOrderInvoice)
|
||||
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
|
||||
list_filter = ('datum', 'is_active', 'work_order')
|
||||
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
||||
ordering = ('-datum', '-created_at')
|
||||
|
||||
@admin.register(VehicleServiceRecord)
|
||||
class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
|
||||
list_filter = ('service_date', 'performed_by', 'vehicle')
|
||||
search_fields = ('service_title', 'vehicle__registration_number', 'performed_by__username', 'description', 'parts')
|
||||
readonly_fields = ('id', 'service_date', 'next_service_due_at')
|
||||
ordering = ('-service_date',)
|
||||
inlines = (ServicePhotoInline, ServiceAttachmentInline)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date'),
|
||||
}),
|
||||
('Detalji servisa', {
|
||||
'fields': ('description', 'parts', 'mileage', 'cost', 'next_service_due_at'),
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.display(description="Opis")
|
||||
def short_description(self, obj):
|
||||
if obj.description:
|
||||
return obj.description[:60] + ('…' if len(obj.description) > 60 else '')
|
||||
return "—"
|
||||
8
backend/modules/fleet/apps.py
Normal file
8
backend/modules/fleet/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# /backend/modules/fleet/apps.py
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
class FleetConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'modules.fleet'
|
||||
verbose_name = "Fleet"
|
||||
124
backend/modules/fleet/migrations/0001_initial.py
Normal file
124
backend/modules/fleet/migrations/0001_initial.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-01 11:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import modules.fleet.models
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Vehicle',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('registration_number', models.CharField(max_length=20, unique=True, verbose_name='Registracija')),
|
||||
('make', models.CharField(blank=True, max_length=100, verbose_name='Proizvođač')),
|
||||
('model', models.CharField(blank=True, max_length=100, verbose_name='Model')),
|
||||
('year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Godina')),
|
||||
('vin', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='VIN')),
|
||||
('current_mileage', models.PositiveIntegerField(default=0, verbose_name='Trenutna kilometraža (km)')),
|
||||
('service_interval_km', models.PositiveIntegerField(default=15000, verbose_name='Interval servisa (km)')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivno')),
|
||||
('assigned_servicer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Vozilo',
|
||||
'verbose_name_plural': 'Vozila',
|
||||
'ordering': ['registration_number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VehicleNotification',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
|
||||
('title', models.CharField(max_length=255, verbose_name='Naslov')),
|
||||
('message', models.TextField(verbose_name='Poruka')),
|
||||
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Upozorenje'), ('critical', 'Kritično')], default='info', max_length=20, verbose_name='Razina')),
|
||||
('is_sent', models.BooleanField(default=False, verbose_name='Poslano (e-mail)')),
|
||||
('is_read', models.BooleanField(default=False, verbose_name='Pročitano')),
|
||||
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicle_notifications', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Notifikacija za vozilo',
|
||||
'verbose_name_plural': 'Notifikacije za vozila',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VehicleServiceRecord',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
|
||||
('description', models.TextField(verbose_name='Opis posla')),
|
||||
('parts', models.TextField(blank=True, verbose_name='Korišteni dijelovi')),
|
||||
('cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Trošak')),
|
||||
('service_date', models.DateField(auto_now_add=True, verbose_name='Datum servisa')),
|
||||
('mileage', models.PositiveIntegerField(verbose_name='Kilometraža pri servisu')),
|
||||
('next_service_due_at', models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža sljedećeg servisa')),
|
||||
('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='performed_vehicle_services', to=settings.AUTH_USER_MODEL, verbose_name='Izvršio')),
|
||||
('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.vehicle', verbose_name='Vozilo')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Servisni zapis',
|
||||
'verbose_name_plural': 'Servisni zapisi',
|
||||
'ordering': ['-service_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VehicleServicePhoto',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('image', models.ImageField(upload_to=modules.fleet.models._service_photo_upload_to, verbose_name='Fotografija')),
|
||||
('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_service_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
|
||||
('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Fotografija servisa',
|
||||
'verbose_name_plural': 'Fotografije servisa',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkOrder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
|
||||
('date', models.DateField(auto_now_add=True, verbose_name='Datum')),
|
||||
('start_mileage', models.PositiveIntegerField(verbose_name='Početna kilometraža')),
|
||||
('end_mileage', models.PositiveIntegerField(blank=True, null=True, verbose_name='Završna kilometraža')),
|
||||
('purpose', models.CharField(blank=True, max_length=255, verbose_name='Svrha')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Bilješke')),
|
||||
('status', models.CharField(choices=[('open', 'Otvoren'), ('closed', 'Zatvoren')], default='open', max_length=20, verbose_name='Status')),
|
||||
('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_work_orders', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
|
||||
('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='work_orders', to='fleet.vehicle', verbose_name='Vozilo')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Putni nalog',
|
||||
'verbose_name_plural': 'Putni nalozi',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-03 02:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='vehicle',
|
||||
name='assigned_servicer',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 08:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0001_initial'),
|
||||
('fleet', '0002_alter_vehicle_assigned_servicer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='client',
|
||||
field=models.ForeignKey(blank=True, help_text='Popunite samo za vozila kupaca (is_company_vehicle = Ne).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicles', to='crm.client', verbose_name='Kupac'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='is_company_vehicle',
|
||||
field=models.BooleanField(default=True, help_text='Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.', verbose_name='Vozilo tvrtke'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 11:45
|
||||
|
||||
import django.db.models.deletion
|
||||
import modules.fleet.models
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('fleet', '0003_add_is_company_vehicle_and_client_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VehicleServiceAttachment',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
|
||||
('file', models.FileField(upload_to=modules.fleet.models._service_attachment_upload_to, verbose_name='Datoteka')),
|
||||
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
|
||||
('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Prilog servisa',
|
||||
'verbose_name_plural': 'Prilozi servisa',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0004_vehicleserviceattachment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehiclenotification',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name='Metapodaci'),
|
||||
),
|
||||
]
|
||||
26
backend/modules/fleet/migrations/0006_crane_proxy_model.py
Normal file
26
backend/modules/fleet/migrations/0006_crane_proxy_model.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 13:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0005_vehiclenotification_metadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Crane',
|
||||
fields=[],
|
||||
options={
|
||||
'verbose_name': 'Dizalica',
|
||||
'verbose_name_plural': 'Dizalice',
|
||||
'ordering': ['registration_number'],
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('fleet.vehicle',),
|
||||
),
|
||||
]
|
||||
18
backend/modules/fleet/migrations/0007_vehicle_asset_type.py
Normal file
18
backend/modules/fleet/migrations/0007_vehicle_asset_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 15:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0006_crane_proxy_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='asset_type',
|
||||
field=models.CharField(choices=[('vehicle', 'Vozilo'), ('crane', 'Dizalica')], db_index=True, default='crane', max_length=20, verbose_name='Tip sredstva'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-06 13:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_asset_type_vehicle(apps, schema_editor):
|
||||
Vehicle = apps.get_model('fleet', 'Vehicle')
|
||||
WorkOrder = apps.get_model('fleet', 'WorkOrder')
|
||||
|
||||
work_order_vehicle_ids = WorkOrder.objects.values_list('vehicle_id', flat=True)
|
||||
service_vehicle_ids = (
|
||||
Vehicle.objects
|
||||
.filter(
|
||||
assigned_servicer__isnull=False,
|
||||
is_company_vehicle=True,
|
||||
client__isnull=True,
|
||||
)
|
||||
.exclude(id__in=work_order_vehicle_ids)
|
||||
.values_list('id', flat=True)
|
||||
)
|
||||
|
||||
Vehicle.objects.filter(id__in=service_vehicle_ids).update(asset_type='vehicle')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0007_vehicle_asset_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_asset_type_vehicle, migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 17:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0008_backfill_asset_type_vehicle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='location',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija odredišta'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='travel_end_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Kraj puta'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='travel_start_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Početak puta'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 17:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def assign_work_order_from_vehicle(apps, schema_editor):
|
||||
WorkOrder = apps.get_model('fleet', 'WorkOrder')
|
||||
VehicleServiceRecord = apps.get_model('fleet', 'VehicleServiceRecord')
|
||||
|
||||
for record in VehicleServiceRecord.objects.filter(work_order__isnull=True).iterator():
|
||||
work_order = (
|
||||
WorkOrder.objects
|
||||
.filter(vehicle_id=record.vehicle_id, is_active=True)
|
||||
.order_by('-created_at')
|
||||
.first()
|
||||
)
|
||||
if work_order is not None:
|
||||
record.work_order_id = work_order.id
|
||||
record.save(update_fields=['work_order'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0009_workorder_location_and_travel_times'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='work_order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.workorder', verbose_name='Radni zadatak'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='mileage',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža pri servisu'),
|
||||
),
|
||||
migrations.RunPython(assign_work_order_from_vehicle, migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 17:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0010_service_record_work_order_and_optional_mileage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorder',
|
||||
name='start_mileage',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Početna kilometraža'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 17:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0011_workorder_start_mileage_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorder',
|
||||
name='purpose',
|
||||
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled')], max_length=50, verbose_name='Svrha'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task_management', '0002_task_work_order'),
|
||||
('fleet', '0012_workorder_purpose_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='task',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='service_records',
|
||||
to='task_management.task',
|
||||
verbose_name='Task',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0013_vehicleservicerecord_task'),
|
||||
('task_management', '0003_task_vehicle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='work_order',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='task',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='service_records',
|
||||
to='task_management.task',
|
||||
verbose_name='Radni zadatak',
|
||||
),
|
||||
),
|
||||
]
|
||||
47
backend/modules/fleet/migrations/0015_workorderinvoice.py
Normal file
47
backend/modules/fleet/migrations/0015_workorderinvoice.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
def work_order_invoice_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_invoices/{unique_name}"
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0014_servicerecord_remove_workorder'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkOrderInvoice',
|
||||
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)),
|
||||
('naziv_racuna', models.CharField(max_length=255, verbose_name='Naziv računa')),
|
||||
('lokacija', models.CharField(blank=True, max_length=255, verbose_name='Lokacija')),
|
||||
('datum', models.DateField(default=django.utils.timezone.now, verbose_name='Datum')),
|
||||
('opis', models.TextField(blank=True, verbose_name='Opis')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to=work_order_invoice_upload_to, verbose_name='Slika računa')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_work_order_invoices', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
|
||||
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Račun putnog naloga',
|
||||
'verbose_name_plural': 'Računi putnog naloga',
|
||||
'ordering': ['-datum', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0015_workorderinvoice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='servicer_vehicle_end_mileage',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - završna kilometraža'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='servicer_vehicle_fuel_cost',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Vozilo servisera - trošak goriva (EUR)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='servicer_vehicle_make_model',
|
||||
field=models.CharField(blank=True, max_length=120, verbose_name='Vozilo servisera - marka/model'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='servicer_vehicle_registration',
|
||||
field=models.CharField(blank=True, max_length=20, verbose_name='Vozilo servisera - registracija'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='servicer_vehicle_start_mileage',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - početna kilometraža'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0016_workorder_servicer_vehicle_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='crane_serial_number',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text='Serijski broj dizalice mora imati točno 6 znamenki.',
|
||||
max_length=6,
|
||||
null=True,
|
||||
verbose_name='Serijski broj dizalice',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-09 12:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0017_vehicle_crane_serial_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='chassis_working_hours',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Radni sati podvozja'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vehicle',
|
||||
name='superstructure_working_hours',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Radni sati nadogradnje'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0018_vehicle_chassis_working_hours_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vehicleservicerecord',
|
||||
name='service_title',
|
||||
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Naziv servisnog zapisa'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-09 16:41
|
||||
|
||||
import modules.fleet.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0019_vehicleservicerecord_service_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorderinvoice',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderinvoice',
|
||||
name='id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderinvoice',
|
||||
name='image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Slika računa'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderinvoice',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||
),
|
||||
]
|
||||
0
backend/modules/fleet/migrations/__init__.py
Normal file
0
backend/modules/fleet/migrations/__init__.py
Normal file
433
backend/modules/fleet/models.py
Normal file
433
backend/modules/fleet/models.py
Normal file
@@ -0,0 +1,433 @@
|
||||
import uuid
|
||||
import re
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from core.base_models import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
def _service_photo_upload_to(instance, filename):
|
||||
"""
|
||||
Putanja za upload:
|
||||
fleet/{registration_number}/service_photos/{uuid4}_{original_filename}
|
||||
Koristimo uuid jer instance.service_record.pk možda nije dostupna prije prvog save-a.
|
||||
"""
|
||||
vehicle_reg = 'unknown'
|
||||
try:
|
||||
vehicle_reg = instance.service_record.vehicle.registration_number
|
||||
except Exception:
|
||||
# fallback
|
||||
vehicle_reg = 'unknown'
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
return f"fleet/{vehicle_reg}/service_photos/{unique_name}"
|
||||
|
||||
def _service_attachment_upload_to(instance, filename):
|
||||
"""
|
||||
Putanja za upload:
|
||||
fleet/{registration_number}/service_attachments/{uuid4}_{original_filename}
|
||||
"""
|
||||
vehicle_reg = 'unknown'
|
||||
try:
|
||||
vehicle_reg = instance.service_record.vehicle.registration_number
|
||||
except Exception:
|
||||
vehicle_reg = 'unknown'
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
return f"fleet/{vehicle_reg}/service_attachments/{unique_name}"
|
||||
|
||||
def _work_order_invoice_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_invoices/{unique_name}"
|
||||
|
||||
|
||||
class VehicleServicePhoto(BaseModel):
|
||||
"""
|
||||
Fotografije kvarova / radova povezane s VehicleServiceRecord.
|
||||
Pohranjuju se slike i opcionalni opis te tko ih je upload-ao.
|
||||
"""
|
||||
service_record = models.ForeignKey(
|
||||
'fleet.VehicleServiceRecord',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='photos',
|
||||
verbose_name=_("Servisni zapis")
|
||||
)
|
||||
image = models.ImageField(
|
||||
upload_to=_service_photo_upload_to,
|
||||
verbose_name=_("Fotografija")
|
||||
)
|
||||
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_service_photos',
|
||||
verbose_name=_("Postavio")
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Fotografija servisa")
|
||||
verbose_name_plural = _("Fotografije servisa")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Photo {self.pk} for {self.service_record} ({self.image.name if self.image else 'no-image'})"
|
||||
|
||||
class VehicleServiceAttachment(BaseModel):
|
||||
"""
|
||||
Datoteke (PDF, DOC, XLS...) povezane sa servisnim zapisom.
|
||||
"""
|
||||
service_record = models.ForeignKey(
|
||||
'fleet.VehicleServiceRecord',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attachments',
|
||||
verbose_name=_("Servisni zapis")
|
||||
)
|
||||
file = models.FileField(
|
||||
upload_to=_service_attachment_upload_to,
|
||||
verbose_name=_("Datoteka")
|
||||
)
|
||||
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_service_attachments',
|
||||
verbose_name=_("Postavio")
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Prilog servisa")
|
||||
verbose_name_plural = _("Prilozi servisa")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
||||
|
||||
class Vehicle(BaseModel):
|
||||
ASSET_TYPE_CHOICES = [
|
||||
('vehicle', _("Vozilo")),
|
||||
('crane', _("Dizalica")),
|
||||
]
|
||||
|
||||
asset_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ASSET_TYPE_CHOICES,
|
||||
default='crane',
|
||||
db_index=True,
|
||||
verbose_name=_("Tip sredstva"),
|
||||
)
|
||||
registration_number = models.CharField(max_length=20, unique=True, verbose_name=_("Registracija"))
|
||||
make = models.CharField(max_length=100, blank=True, verbose_name=_("Proizvođač"))
|
||||
model = models.CharField(max_length=100, blank=True, verbose_name=_("Model"))
|
||||
year = models.PositiveSmallIntegerField(null=True, blank=True, verbose_name=_("Godina"))
|
||||
vin = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("VIN"))
|
||||
crane_serial_number = models.CharField(
|
||||
max_length=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name=_("Serijski broj dizalice"),
|
||||
help_text=_("Serijski broj dizalice mora imati točno 6 znamenki."),
|
||||
)
|
||||
superstructure_working_hours = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Radni sati nadogradnje"),
|
||||
)
|
||||
chassis_working_hours = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Radni sati podvozja"),
|
||||
)
|
||||
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
|
||||
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
|
||||
assigned_servicer = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='assigned_vehicles',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Serviser")
|
||||
)
|
||||
# Označava je li vozilo u vlasništvu naše tvrtke (fleet) ili kupca
|
||||
is_company_vehicle = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Vozilo tvrtke"),
|
||||
help_text=_("Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.")
|
||||
)
|
||||
# Kupac kojemu pripada vozilo (samo za vozila kupaca)
|
||||
client = models.ForeignKey(
|
||||
'crm.Client',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='vehicles',
|
||||
verbose_name=_("Kupac"),
|
||||
help_text=_("Popunite samo za vozila kupaca (is_company_vehicle = Ne).")
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Aktivno"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Vozilo")
|
||||
verbose_name_plural = _("Vozila")
|
||||
ordering = ['registration_number']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.registration_number} - {self.make} {self.model}"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.asset_type == 'crane':
|
||||
if not self.crane_serial_number and self._state.adding:
|
||||
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice je obavezan za dizalice.")})
|
||||
if self.crane_serial_number and not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
|
||||
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
|
||||
elif self.crane_serial_number:
|
||||
if not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
|
||||
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
|
||||
|
||||
def next_service_at(self) -> int:
|
||||
"""
|
||||
Vrati kilometražu pri kojoj sljedeći servis pada, bazirano na trenutnoj kilometraži i intervalu.
|
||||
Npr. current=12000, interval=15000 -> next_service_at = 15000
|
||||
Npr. current=32000, interval=15000 -> next_service_at = 45000
|
||||
"""
|
||||
if self.service_interval_km <= 0:
|
||||
return 0
|
||||
blocks = (self.current_mileage // self.service_interval_km) + 1
|
||||
return blocks * self.service_interval_km
|
||||
|
||||
|
||||
class Crane(Vehicle):
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _("Dizalica")
|
||||
verbose_name_plural = _("Dizalice")
|
||||
ordering = ['registration_number']
|
||||
|
||||
def __str__(self):
|
||||
details = " ".join(part for part in [self.make, self.model] if part)
|
||||
if details:
|
||||
return f"{self.registration_number} - {details}"
|
||||
return self.registration_number
|
||||
|
||||
class WorkOrder(BaseModel):
|
||||
"""
|
||||
Putni nalog / radni nalog koji prati kilometražu vozila tijekom zadatka na terenu.
|
||||
Kad se postavi end_mileage, model ažurira vehicle.current_mileage ako je end_mileage veći.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('open', 'Otvoren'),
|
||||
('closed', 'Zatvoren'),
|
||||
]
|
||||
PURPOSE_CHOICES = [
|
||||
('defektaza', _("Defektaža")),
|
||||
('kontrola', _("Kontrola")),
|
||||
('redovni_pregled', _("Redovni pregled")),
|
||||
]
|
||||
|
||||
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
|
||||
creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_work_orders', verbose_name=_("Kreirao"))
|
||||
date = models.DateField(auto_now_add=True, verbose_name=_("Datum"))
|
||||
start_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Početna kilometraža"))
|
||||
end_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Završna kilometraža"))
|
||||
servicer_vehicle_registration = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - registracija"),
|
||||
)
|
||||
servicer_vehicle_make_model = models.CharField(
|
||||
max_length=120,
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - marka/model"),
|
||||
)
|
||||
servicer_vehicle_start_mileage = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - početna kilometraža"),
|
||||
)
|
||||
servicer_vehicle_end_mileage = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - završna kilometraža"),
|
||||
)
|
||||
servicer_vehicle_fuel_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
|
||||
)
|
||||
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
|
||||
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
|
||||
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
|
||||
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
|
||||
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Putni nalog")
|
||||
verbose_name_plural = _("Putni nalozi")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"WO-{self.pk} {self.vehicle.registration_number} ({self.status})"
|
||||
|
||||
@property
|
||||
def distance(self):
|
||||
if self.end_mileage is None or self.start_mileage is None:
|
||||
return None
|
||||
return max(0, self.end_mileage - self.start_mileage)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean() # Provjera validnosti prije svega
|
||||
updating_vehicle = False
|
||||
if self.end_mileage is not None:
|
||||
# Ako je end_mileage veći od trenutne vehicle.current_mileage, ažuriraj vozilo.
|
||||
if self.end_mileage > (self.vehicle.current_mileage or 0):
|
||||
self.vehicle.current_mileage = self.end_mileage
|
||||
updating_vehicle = True
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if updating_vehicle:
|
||||
# Spremi vozilo (nakon što nalog ima PK)
|
||||
self.vehicle.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
|
||||
raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})
|
||||
|
||||
|
||||
class WorkOrderInvoice(BaseModel):
|
||||
work_order = models.ForeignKey(
|
||||
WorkOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='invoices',
|
||||
verbose_name=_("Putni nalog"),
|
||||
)
|
||||
naziv_racuna = models.CharField(max_length=255, verbose_name=_("Naziv računa"))
|
||||
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
|
||||
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
|
||||
opis = models.TextField(blank=True, verbose_name=_("Opis"))
|
||||
image = models.ImageField(
|
||||
upload_to=_work_order_invoice_upload_to,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Slika računa"),
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_work_order_invoices',
|
||||
verbose_name=_("Kreirao"),
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Račun putnog naloga")
|
||||
verbose_name_plural = _("Računi putnog naloga")
|
||||
ordering = ['-datum', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.naziv_racuna} ({self.work_order_id})"
|
||||
|
||||
|
||||
class VehicleServiceRecord(BaseModel):
|
||||
"""
|
||||
Zapisi o servisima/popravcima na vozilu.
|
||||
"""
|
||||
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='service_records', verbose_name=_("Vozilo"))
|
||||
task = models.ForeignKey(
|
||||
'task_management.Task',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='service_records',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Radni zadatak"),
|
||||
)
|
||||
performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='performed_vehicle_services', verbose_name=_("Izvršio"))
|
||||
service_title = models.CharField(max_length=255, blank=True, default='', verbose_name=_("Naziv servisnog zapisa"))
|
||||
description = models.TextField(verbose_name=_("Opis posla"))
|
||||
parts = models.TextField(blank=True, verbose_name=_("Korišteni dijelovi"))
|
||||
cost = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name=_("Trošak"))
|
||||
service_date = models.DateField(auto_now_add=True, verbose_name=_("Datum servisa"))
|
||||
mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža pri servisu"))
|
||||
next_service_due_at = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža sljedećeg servisa"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Servisni zapis")
|
||||
verbose_name_plural = _("Servisni zapisi")
|
||||
ordering = ['-service_date']
|
||||
|
||||
def __str__(self):
|
||||
mileage_display = self.mileage if self.mileage is not None else "-"
|
||||
title = (self.service_title or '').strip()
|
||||
if title:
|
||||
return f"{title} ({self.vehicle.registration_number} @ {mileage_display})"
|
||||
return f"Service {self.vehicle.registration_number} @ {mileage_display}"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.task_id and self.vehicle_id:
|
||||
task = self.task
|
||||
if task.vehicle_id and task.vehicle_id != self.vehicle_id:
|
||||
raise ValidationError({'task': _("Radni zadatak mora biti povezan s istom dizalicom kao servisni zapis.")})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean() # Provjera validnosti prije svega
|
||||
# Ako nije zadan next_service_due_at, postavi prema vehicle.service_interval_km
|
||||
if self.next_service_due_at is None and self.mileage is not None and self.vehicle and self.vehicle.service_interval_km:
|
||||
self.next_service_due_at = self.mileage + self.vehicle.service_interval_km
|
||||
|
||||
# VAŽNO: ažuriraj vehicle.current_mileage PRIJE save-a kako bi refresh-om nakon toga korisnik vidio svježe podatke
|
||||
if self.mileage is not None and self.mileage > (self.vehicle.current_mileage or 0):
|
||||
self.vehicle.current_mileage = self.mileage
|
||||
self.vehicle.save(update_fields=['current_mileage'])
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class VehicleNotification(BaseModel):
|
||||
"""
|
||||
Jednostavni zapis notifikacije vezane uz fleet objekte.
|
||||
Može se koristiti za in-app obavijesti ili kao log koji je poslan.
|
||||
"""
|
||||
LEVEL_CHOICES = [
|
||||
('info', 'Info'),
|
||||
('warning', 'Upozorenje'),
|
||||
('critical', 'Kritično'),
|
||||
]
|
||||
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='vehicle_notifications',
|
||||
verbose_name=_("Primatelj")
|
||||
)
|
||||
title = models.CharField(max_length=255, verbose_name=_("Naslov"))
|
||||
message = models.TextField(verbose_name=_("Poruka"))
|
||||
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
|
||||
level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default='info', verbose_name=_("Razina"))
|
||||
is_sent = models.BooleanField(default=False, verbose_name=_("Poslano (e-mail)"))
|
||||
is_read = models.BooleanField(default=False, verbose_name=_("Pročitano"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Notifikacija za vozilo")
|
||||
verbose_name_plural = _("Notifikacije za vozila")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} -> {self.recipient} ({self.level})"
|
||||
314
backend/modules/fleet/serializers.py
Normal file
314
backend/modules/fleet/serializers.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from rest_framework import serializers
|
||||
import re
|
||||
from .models import (
|
||||
Crane,
|
||||
Vehicle,
|
||||
VehicleNotification,
|
||||
WorkOrder,
|
||||
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', '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',
|
||||
'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},
|
||||
'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},
|
||||
'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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WorkOrderInvoice
|
||||
fields = [
|
||||
'id',
|
||||
'work_order',
|
||||
'naziv_racuna',
|
||||
'lokacija',
|
||||
'datum',
|
||||
'opis',
|
||||
'image',
|
||||
'created_by',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_by', 'created_at']
|
||||
|
||||
|
||||
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()
|
||||
|
||||
class Meta:
|
||||
model = VehicleServiceRecord
|
||||
fields = [
|
||||
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
|
||||
'task', 'task_label',
|
||||
'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 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)
|
||||
412
backend/modules/fleet/services.py
Normal file
412
backend/modules/fleet/services.py
Normal file
@@ -0,0 +1,412 @@
|
||||
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:
|
||||
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()
|
||||
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
|
||||
if instance.status == '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)
|
||||
165
backend/modules/fleet/tasks.py
Normal file
165
backend/modules/fleet/tasks.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# backend/modules/fleet/tasks.py
|
||||
from celery import shared_task
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
from .models import VehicleNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def send_notification_email(self, notification_id):
|
||||
"""
|
||||
Celery task koji pošalje e-mail za VehicleNotification i označi is_sent = True.
|
||||
Prihvaća notification_id (PK) i radi safe-lookup.
|
||||
U slučaju greške rekreira retry.
|
||||
"""
|
||||
try:
|
||||
# Lokalni import kako bismo izbjegli kružne importove
|
||||
from .models import VehicleNotification
|
||||
notif = VehicleNotification.objects.select_related('recipient').get(pk=notification_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Notification {notification_id} ne postoji ili nije dostupna: {exc}")
|
||||
return
|
||||
|
||||
recipient = notif.recipient
|
||||
recipient_email = getattr(recipient, 'email', None) if recipient else None
|
||||
|
||||
if not recipient_email:
|
||||
logger.info(f"Notification {notification_id} nema email primatelja; preskačem slanje.")
|
||||
return
|
||||
|
||||
subject = notif.title
|
||||
message = notif.message
|
||||
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=from_email,
|
||||
recipient_list=[recipient_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
notif.is_sent = True
|
||||
notif.save(update_fields=['is_sent'])
|
||||
logger.info(f"Notification email poslan za Notification(id={notification_id}) na {recipient_email}")
|
||||
except Exception as exc:
|
||||
logger.exception(f"Greška pri slanju emaila za Notification(id={notification_id}): {exc}")
|
||||
try:
|
||||
# Retry with exponential backoff handled by celery config; here max_retries = 3
|
||||
raise self.retry(exc=exc)
|
||||
except self.MaxRetriesExceededError:
|
||||
logger.error(f"Max retries exceeded za Notification(id={notification_id})")
|
||||
|
||||
@shared_task
|
||||
def send_email_task(recipient_email, subject, message, notif_id):
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[recipient_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
VehicleNotification.objects.filter(id=notif_id).update(is_sent=True)
|
||||
except Exception as exc:
|
||||
logger.error(f"Async mail failed: {exc}")
|
||||
|
||||
|
||||
def _build_work_order_invoices_pdf(work_order):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
width, height = A4
|
||||
y = height - 50
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
|
||||
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
||||
if not invoices.exists():
|
||||
y -= 24
|
||||
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
for index, invoice in enumerate(invoices, start=1):
|
||||
pdf.showPage()
|
||||
y = height - 50
|
||||
pdf.setFont("Helvetica-Bold", 13)
|
||||
pdf.drawString(40, y, f"Račun #{index}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||
y -= 20
|
||||
|
||||
if not invoice.image:
|
||||
pdf.drawString(40, y, "slika: Nema slike.")
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB')
|
||||
max_width = width - 80
|
||||
max_height = y - 60
|
||||
if max_height < 120:
|
||||
pdf.showPage()
|
||||
y = height - 60
|
||||
max_height = y - 60
|
||||
|
||||
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
||||
draw_width = max(1, int(image.width * ratio))
|
||||
draw_height = max(1, int(image.height * ratio))
|
||||
image_reader = ImageReader(image)
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
40,
|
||||
y - draw_height,
|
||||
width=draw_width,
|
||||
height=draw_height,
|
||||
preserveAspectRatio=True,
|
||||
mask='auto',
|
||||
)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@shared_task
|
||||
def build_work_order_invoices_pdf_task(work_order_id):
|
||||
from .models import WorkOrder
|
||||
|
||||
work_order = WorkOrder.objects.select_related('vehicle').filter(pk=work_order_id).first()
|
||||
if work_order is None:
|
||||
return {"error": "Putni nalog nije pronađen."}
|
||||
|
||||
pdf_bytes = _build_work_order_invoices_pdf(work_order)
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
|
||||
filename = f"{work_order.pk}.work-order-invoices.pdf"
|
||||
return {
|
||||
"filename": filename,
|
||||
"pdf_base64": pdf_b64,
|
||||
}
|
||||
18
backend/modules/fleet/tests/conftest.py
Normal file
18
backend/modules/fleet/tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# conftest.py
|
||||
import pytest
|
||||
import uuid
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@pytest.fixture
|
||||
def test_user():
|
||||
return get_user_model().objects.create_user(
|
||||
username=f"user_{uuid.uuid4().hex[:8]}",
|
||||
email=f"user+{uuid.uuid4().hex[:8]}@example.test",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_celery_settings(settings):
|
||||
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||
settings.CELERY_TASK_EAGER_PROPAGATES=True # Ovo osigurava da greške u tasku odmah pucaju u testu
|
||||
settings.BROKER_BACKEND='memory', # Koristi in-memory broker umjesto Redis-a
|
||||
72
backend/modules/fleet/tests/test_bulk_close_work_orders.py
Normal file
72
backend/modules/fleet/tests/test_bulk_close_work_orders.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
from modules.fleet.models import Vehicle, WorkOrder
|
||||
from modules.fleet.services import FleetService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_close_work_orders_updates_mileage(test_user):
|
||||
|
||||
# 1. Kreiraj vozilo
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-001", current_mileage=1000)
|
||||
|
||||
# 2. Kreiraj više otvorenih naloga
|
||||
wo1 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1200)
|
||||
wo2 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1200, end_mileage=1500)
|
||||
|
||||
# 3. Pozovi bulk servis
|
||||
queryset = WorkOrder.objects.filter(pk__in=[wo1.pk, wo2.pk])
|
||||
FleetService.bulk_close_work_orders(queryset)
|
||||
|
||||
# 4. Provjeri rezultate
|
||||
vehicle.refresh_from_db()
|
||||
wo1.refresh_from_db()
|
||||
wo2.refresh_from_db()
|
||||
|
||||
# Statusi moraju biti zatvoreni
|
||||
assert wo1.status == 'closed'
|
||||
assert wo2.status == 'closed'
|
||||
|
||||
# Kilometraža vozila mora biti najveća (1500)
|
||||
assert vehicle.current_mileage == 1500
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_close_ignores_already_closed_orders(test_user):
|
||||
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-002", current_mileage=1000)
|
||||
|
||||
# Već zatvoren nalog
|
||||
wo = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1100, status='closed')
|
||||
|
||||
# Pozovi servis
|
||||
FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
|
||||
|
||||
# Provjeri da nalog nije "pukao" niti promijenio stanje
|
||||
wo.refresh_from_db()
|
||||
assert wo.status == 'closed'
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_close_ignores_incomplete_work_order(test_user):
|
||||
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-003", current_mileage=1000)
|
||||
|
||||
# Nalog koji je otvoren i NEMA end_mileage
|
||||
wo = WorkOrder.objects.create(
|
||||
vehicle=vehicle,
|
||||
creator=test_user,
|
||||
start_mileage=1000,
|
||||
end_mileage=None, # Incomplete
|
||||
status='open'
|
||||
)
|
||||
|
||||
# Pozovi servis
|
||||
FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
|
||||
|
||||
# Provjeri: Nalog bi trebao ostati otvoren ili se možda ne bi smio zatvoriti
|
||||
# jer podaci nisu kompletni
|
||||
wo.refresh_from_db()
|
||||
vehicle.refresh_from_db()
|
||||
|
||||
# Ovisno o tvojoj poslovnoj logici:
|
||||
# Ako nalog nije kompletan, ne bi ga se smjelo zatvoriti.
|
||||
assert wo.status == 'open'
|
||||
assert vehicle.current_mileage == 1000
|
||||
67
backend/modules/fleet/tests/test_crane_serial_number.py
Normal file
67
backend/modules/fleet/tests/test_crane_serial_number.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from modules.fleet.serializers import CraneSerializer
|
||||
|
||||
|
||||
class CraneSerialNumberTests(TestCase):
|
||||
def _base_payload(self):
|
||||
suffix = uuid.uuid4().hex[:6].upper()
|
||||
return {
|
||||
"registration_number": f"ZG-CRN-{suffix}",
|
||||
"make": "Liebherr",
|
||||
"model": "LTM",
|
||||
"year": 2024,
|
||||
"current_mileage": 12000,
|
||||
"service_interval_km": 8000,
|
||||
"is_company_vehicle": True,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
def test_create_crane_with_serial_number_succeeds(self):
|
||||
payload = {
|
||||
**self._base_payload(),
|
||||
"crane_serial_number": "123456",
|
||||
}
|
||||
serializer = CraneSerializer(data=payload)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
crane = serializer.save()
|
||||
self.assertEqual(crane.asset_type, "crane")
|
||||
self.assertEqual(crane.crane_serial_number, "123456")
|
||||
|
||||
def test_create_crane_without_serial_number_fails(self):
|
||||
payload = self._base_payload()
|
||||
serializer = CraneSerializer(data=payload)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("crane_serial_number", serializer.errors)
|
||||
|
||||
def test_crane_serial_number_industry_standard_validation(self):
|
||||
valid_payload = {
|
||||
**self._base_payload(),
|
||||
"registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
|
||||
"crane_serial_number": "000123",
|
||||
}
|
||||
valid_serializer = CraneSerializer(data=valid_payload)
|
||||
self.assertTrue(valid_serializer.is_valid(), valid_serializer.errors)
|
||||
|
||||
invalid_serials = [
|
||||
"12345", # prekratko
|
||||
"1234567", # predugo
|
||||
"12A456", # slova nisu dozvoljena
|
||||
"12 456", # razmaci nisu dozvoljeni
|
||||
"12-456", # specijalni znakovi nisu dozvoljeni
|
||||
"123456", # unicode znamenke nisu dozvoljene (samo ASCII 0-9)
|
||||
]
|
||||
|
||||
for value in invalid_serials:
|
||||
with self.subTest(serial=value):
|
||||
payload = {
|
||||
**self._base_payload(),
|
||||
"registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
|
||||
"crane_serial_number": value,
|
||||
}
|
||||
serializer = CraneSerializer(data=payload)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("crane_serial_number", serializer.errors)
|
||||
24
backend/modules/fleet/tests/test_delete_work_order.py
Normal file
24
backend/modules/fleet/tests/test_delete_work_order.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleNotification
|
||||
from modules.fleet.services import VehicleService
|
||||
import uuid
|
||||
|
||||
class WorkOrderDeleteTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="u1", email=f"u1+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.other = User.objects.create_user(username="u2", email=f"u2+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.vehicle = Vehicle.objects.create(registration_number=f"V-{uuid.uuid4().hex[:6]}", current_mileage=1000)
|
||||
self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=1000)
|
||||
|
||||
def test_delete_by_creator_creates_notification(self):
|
||||
VehicleService.delete_work_order(self.wo, user=self.user)
|
||||
self.wo.refresh_from_db()
|
||||
self.assertFalse(self.wo.is_active)
|
||||
notif = VehicleNotification.objects.filter(recipient=self.user, title__icontains="obrisan").exists()
|
||||
self.assertTrue(notif)
|
||||
|
||||
def test_delete_by_non_creator_denied(self):
|
||||
with self.assertRaises(Exception):
|
||||
VehicleService.delete_work_order(self.wo, user=self.other)
|
||||
28
backend/modules/fleet/tests/test_integration.py
Normal file
28
backend/modules/fleet/tests/test_integration.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from modules.fleet.models import Vehicle
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_servicer_one_to_one_constraint(test_user):
|
||||
|
||||
# Prvo vozilo prolazi
|
||||
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
|
||||
|
||||
# Drugo vozilo za istog servisera mora baciti IntegrityError
|
||||
with pytest.raises(Exception): # Django IntegrityError
|
||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_next_service_logic():
|
||||
# current=12000, interval=15000 -> next_service_at = 15000
|
||||
vehicle = Vehicle.objects.create(
|
||||
registration_number="ZG-001",
|
||||
current_mileage=12000,
|
||||
service_interval_km=15000
|
||||
)
|
||||
assert vehicle.next_service_at() == 15000
|
||||
|
||||
# current=16000, interval=15000 -> next_service_at = 30000
|
||||
vehicle.current_mileage = 16000
|
||||
assert vehicle.next_service_at() == 30000
|
||||
33
backend/modules/fleet/tests/test_models.py
Normal file
33
backend/modules/fleet/tests/test_models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
|
||||
|
||||
|
||||
class FleetModelTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="tech", email=f"tech+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.vehicle = Vehicle.objects.create(
|
||||
registration_number=f"ZG-{uuid.uuid4().hex[:6].upper()}",
|
||||
make="Toyota",
|
||||
model="Yaris",
|
||||
current_mileage=12000,
|
||||
service_interval_km=15000,
|
||||
assigned_servicer=self.user
|
||||
)
|
||||
|
||||
def test_next_service_at(self):
|
||||
self.assertEqual(self.vehicle.next_service_at(), 15000)
|
||||
|
||||
def test_work_order_updates_vehicle_mileage(self):
|
||||
wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=12000, end_mileage=12500)
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(self.vehicle.current_mileage, 12500)
|
||||
self.assertEqual(wo.distance, 500)
|
||||
|
||||
def test_service_record_sets_next_service_due_and_updates_vehicle(self):
|
||||
rec = VehicleServiceRecord.objects.create(vehicle=self.vehicle, performed_by=self.user, description="Oil change", mileage=13000, parts="oil;filter", cost="50.00")
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(rec.next_service_due_at, 13000 + self.vehicle.service_interval_km)
|
||||
self.assertEqual(self.vehicle.current_mileage, 13000)
|
||||
201
backend/modules/fleet/tests/test_notifications.py
Normal file
201
backend/modules/fleet/tests/test_notifications.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import uuid
|
||||
from django.test import TestCase, override_settings
|
||||
from django.core import mail
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import patch
|
||||
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification
|
||||
from modules.fleet.services import VehicleService, NotificationService
|
||||
|
||||
|
||||
class NotificationEmailTests(TestCase):
|
||||
@override_settings(
|
||||
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
|
||||
DEFAULT_FROM_EMAIL='no-reply@example.test'
|
||||
)
|
||||
def test_create_notification_sends_email_and_records_is_sent(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass")
|
||||
|
||||
# Ensure outbox is empty
|
||||
mail.outbox.clear()
|
||||
|
||||
title = "Test email notification"
|
||||
message = "This is a test message."
|
||||
|
||||
with patch('django.db.transaction.on_commit', lambda f: f()):
|
||||
notif = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
title=title,
|
||||
message=message,
|
||||
level="info",
|
||||
send_email=True
|
||||
)
|
||||
|
||||
# One email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
sent = mail.outbox[0]
|
||||
self.assertIn(title, sent.subject)
|
||||
self.assertIn(message, sent.body)
|
||||
# The notification record should exist and flagged as sent
|
||||
notif.refresh_from_db()
|
||||
self.assertTrue(notif.is_sent)
|
||||
self.assertEqual(notif.recipient, user)
|
||||
self.assertEqual(notif.title, title)
|
||||
|
||||
|
||||
class NotificationRealtimeTests(TestCase):
|
||||
@patch('modules.fleet.services.PusherService.broadcast_notification')
|
||||
def test_create_notification_broadcasts_after_on_commit(self, mock_broadcast):
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user(
|
||||
username=f"ws_user_{uuid.uuid4().hex[:6]}",
|
||||
email=f"ws+{uuid.uuid4()}@example.test",
|
||||
password="pass"
|
||||
)
|
||||
|
||||
with patch('django.db.transaction.on_commit', lambda fn: fn()):
|
||||
notif = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
title="Realtime test",
|
||||
message="Provjera pusher poruke",
|
||||
level="warning",
|
||||
send_email=False,
|
||||
metadata={"source": "unit-test"}
|
||||
)
|
||||
|
||||
self.assertEqual(notif.recipient, user)
|
||||
mock_broadcast.assert_called_once_with(
|
||||
user_id=user.id,
|
||||
title="Realtime test",
|
||||
message="Provjera pusher poruke",
|
||||
level="warning",
|
||||
notification_id=notif.id,
|
||||
metadata={"source": "unit-test"}
|
||||
)
|
||||
|
||||
@patch('modules.fleet.services.PusherService.broadcast_notification')
|
||||
def test_create_notification_without_recipient_does_not_broadcast(self, mock_broadcast):
|
||||
NotificationService.create_notification(
|
||||
recipient=None,
|
||||
title="No recipient",
|
||||
message="Broadcast se ne smije pozvati",
|
||||
level="info",
|
||||
send_email=False
|
||||
)
|
||||
|
||||
mock_broadcast.assert_not_called()
|
||||
|
||||
|
||||
class WorkOrderAndServiceRecordNotificationTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
# creator who will create work order
|
||||
self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass")
|
||||
# assigned servicer (different user)
|
||||
self.servicer = User.objects.create_user(username=f"servicer_{uuid.uuid4().hex[:6]}", email=f"servicer+{uuid.uuid4()}@example.test", password="pass")
|
||||
# another technician who will perform the service
|
||||
self.technician = User.objects.create_user(username=f"tech_{uuid.uuid4().hex[:6]}", email=f"tech+{uuid.uuid4()}@example.test", password="pass")
|
||||
|
||||
self.vehicle = Vehicle.objects.create(
|
||||
registration_number=f"REG-{uuid.uuid4().hex[:6].upper()}",
|
||||
make="TestMake",
|
||||
model="TestModel",
|
||||
current_mileage=10000,
|
||||
service_interval_km=15000,
|
||||
assigned_servicer=self.servicer
|
||||
)
|
||||
|
||||
def test_create_work_order_creates_notifications_for_creator_and_servicer(self):
|
||||
# Precondition: no notifications
|
||||
VehicleNotification.objects.all().delete()
|
||||
|
||||
data = {
|
||||
"vehicle": self.vehicle,
|
||||
"creator": self.creator,
|
||||
"start_mileage": 10000,
|
||||
"end_mileage": 10120,
|
||||
"purpose": "Routine visit"
|
||||
}
|
||||
|
||||
wo = VehicleService.create_work_order(data)
|
||||
|
||||
# Ensure work order created
|
||||
self.assertIsNotNone(wo.pk)
|
||||
|
||||
# Notifications: one for creator, one for assigned servicer
|
||||
creator_notifs = VehicleNotification.objects.filter(recipient=self.creator)
|
||||
servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer)
|
||||
|
||||
self.assertTrue(creator_notifs.exists(), "Creator should receive a notification")
|
||||
self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification")
|
||||
|
||||
def test_create_service_record_creates_notifications_for_performer_and_assigned_servicer(self):
|
||||
# Precondition: no notifications
|
||||
VehicleNotification.objects.all().delete()
|
||||
|
||||
data = {
|
||||
"vehicle": self.vehicle,
|
||||
"performed_by": self.technician,
|
||||
"description": "Brake replacement",
|
||||
"parts": "pads;discs",
|
||||
"cost": "200.00",
|
||||
"mileage": 10200
|
||||
}
|
||||
|
||||
rec = VehicleService.create_service_record(data)
|
||||
|
||||
# Ensure service record created
|
||||
self.assertIsNotNone(rec.pk)
|
||||
|
||||
# There should be a notification for the performer
|
||||
perf_notifs = VehicleNotification.objects.filter(recipient=self.technician)
|
||||
self.assertTrue(perf_notifs.exists(), "Performer should receive a notification")
|
||||
|
||||
# There should be a notification for the assigned servicer (different from performer)
|
||||
servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer)
|
||||
self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification when different from performer")
|
||||
|
||||
|
||||
class DeleteWorkOrderPermissionTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.other = User.objects.create_user(username=f"other_{uuid.uuid4().hex[:6]}", email=f"other+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.staff = User.objects.create_user(username=f"admin_{uuid.uuid4().hex[:6]}", email=f"admin+{uuid.uuid4()}@example.test", password="pass", is_staff=True)
|
||||
|
||||
self.vehicle = Vehicle.objects.create(
|
||||
registration_number=f"DEL-{uuid.uuid4().hex[:6].upper()}",
|
||||
current_mileage=5000
|
||||
)
|
||||
|
||||
self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.creator, start_mileage=5000)
|
||||
|
||||
def test_creator_can_delete_work_order_and_notification_created(self):
|
||||
# Ensure active initially
|
||||
self.wo.refresh_from_db()
|
||||
self.assertTrue(self.wo.is_active)
|
||||
|
||||
# Delete by creator
|
||||
VehicleService.delete_work_order(self.wo, user=self.creator)
|
||||
|
||||
# Refresh and assert deactivated
|
||||
self.wo.refresh_from_db()
|
||||
self.assertFalse(self.wo.is_active)
|
||||
|
||||
# Notification exists for creator
|
||||
notif_exists = VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists()
|
||||
self.assertTrue(notif_exists)
|
||||
|
||||
def test_non_creator_cannot_delete_work_order(self):
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleService.delete_work_order(self.wo, user=self.other)
|
||||
|
||||
def test_staff_can_delete_work_order(self):
|
||||
# staff should be allowed
|
||||
VehicleService.delete_work_order(self.wo, user=self.staff)
|
||||
self.wo.refresh_from_db()
|
||||
self.assertFalse(self.wo.is_active)
|
||||
# Notification exists for creator as well (as per implementation)
|
||||
self.assertTrue(VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists())
|
||||
362
backend/modules/fleet/tests/test_photo_services.py
Normal file
362
backend/modules/fleet/tests/test_photo_services.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from django.test import TestCase
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from PIL import Image
|
||||
|
||||
from modules.fleet.models import Vehicle, VehicleServiceRecord, VehicleServicePhoto
|
||||
from modules.fleet.services import VehicleServicePhotoService
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def create_test_image(filename="test.jpg", size=(100, 100), color='red'):
|
||||
"""
|
||||
Stvori test sliku u memoriji koristeći Pillow.
|
||||
Vraća SimpleUploadedFile spreman za upload.
|
||||
"""
|
||||
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 VehicleServicePhotoServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
# Create users
|
||||
self.uploader = User.objects.create_user(
|
||||
username=f"uploader_{uuid.uuid4().hex[:6]}",
|
||||
email=f"uploader+{uuid.uuid4()}@example.test",
|
||||
password="pass"
|
||||
)
|
||||
self.other_user = User.objects.create_user(
|
||||
username=f"other_{uuid.uuid4().hex[:6]}",
|
||||
email=f"other+{uuid.uuid4()}@example.test",
|
||||
password="pass"
|
||||
)
|
||||
self.staff_user = User.objects.create_user(
|
||||
username=f"admin_{uuid.uuid4().hex[:6]}",
|
||||
email=f"admin+{uuid.uuid4()}@example.test",
|
||||
password="pass",
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# Create vehicle and service record
|
||||
self.vehicle = Vehicle.objects.create(
|
||||
registration_number=f"SRVPHOTO-{uuid.uuid4().hex[:6].upper()}",
|
||||
make="TestMake",
|
||||
model="TestModel",
|
||||
current_mileage=5000,
|
||||
service_interval_km=10000
|
||||
)
|
||||
|
||||
self.service_record = VehicleServiceRecord.objects.create(
|
||||
vehicle=self.vehicle,
|
||||
performed_by=self.uploader,
|
||||
description="Test service",
|
||||
mileage=5000,
|
||||
parts="part1;part2"
|
||||
)
|
||||
|
||||
def test_upload_photo_success(self):
|
||||
"""
|
||||
Test uspješnog uploada fotografije s valjanim podacima.
|
||||
"""
|
||||
image = create_test_image(filename="success.jpg")
|
||||
|
||||
data = {
|
||||
'service_record': self.service_record,
|
||||
'image': image,
|
||||
'description': 'Test brake replacement'
|
||||
}
|
||||
|
||||
photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
|
||||
|
||||
self.assertIsNotNone(photo.pk)
|
||||
self.assertEqual(photo.uploaded_by, self.uploader)
|
||||
self.assertEqual(photo.service_record, self.service_record)
|
||||
self.assertEqual(photo.description, 'Test brake replacement')
|
||||
self.assertTrue(photo.image)
|
||||
|
||||
def test_upload_photo_with_service_record_id(self):
|
||||
"""
|
||||
Test uploada koristeći service_record ID umjesto instancije.
|
||||
"""
|
||||
image = create_test_image(filename="by_id.jpg")
|
||||
|
||||
data = {
|
||||
'service_record': self.service_record.id, # ID, ne instanca
|
||||
'image': image,
|
||||
'description': 'Upload by ID'
|
||||
}
|
||||
|
||||
photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
|
||||
|
||||
self.assertIsNotNone(photo.pk)
|
||||
self.assertEqual(photo.service_record, self.service_record)
|
||||
|
||||
def test_upload_photo_without_user_raises(self):
|
||||
"""
|
||||
Test — upload bez korisnika (uploaded_by) baca ValidationError.
|
||||
"""
|
||||
image = create_test_image(filename="no_user.jpg")
|
||||
|
||||
data = {
|
||||
'service_record': self.service_record,
|
||||
'image': image,
|
||||
'description': 'No user'
|
||||
}
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.upload_photo(data=data, uploaded_by=None)
|
||||
|
||||
def test_upload_photo_invalid_service_record_raises(self):
|
||||
"""
|
||||
Test — upload s nepostojeću service_record ID baca ValidationError.
|
||||
"""
|
||||
image = create_test_image(filename="invalid_sr.jpg")
|
||||
|
||||
data = {
|
||||
'service_record': 99999, # ne postoji
|
||||
'image': image,
|
||||
'description': 'Invalid SR'
|
||||
}
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
|
||||
|
||||
def test_update_photo_by_uploader_success(self):
|
||||
"""
|
||||
Test ažuriranja fotografije od strane korisnika koji ju je uploadao.
|
||||
"""
|
||||
image = create_test_image(filename="original.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original description",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
update_data = {'description': 'Updated description'}
|
||||
updated_photo = VehicleServicePhotoService.update_photo(
|
||||
instance=photo,
|
||||
data=update_data,
|
||||
user=self.uploader
|
||||
)
|
||||
|
||||
self.assertEqual(updated_photo.description, 'Updated description')
|
||||
photo.refresh_from_db()
|
||||
self.assertEqual(photo.description, 'Updated description')
|
||||
|
||||
def test_update_photo_by_staff_success(self):
|
||||
"""
|
||||
Test ažuriranja fotografije od strane staff korisnika (može ažurirati bilo koju).
|
||||
"""
|
||||
image = create_test_image(filename="staff_update.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
update_data = {'description': 'Staff updated'}
|
||||
updated_photo = VehicleServicePhotoService.update_photo(
|
||||
instance=photo,
|
||||
data=update_data,
|
||||
user=self.staff_user
|
||||
)
|
||||
|
||||
self.assertEqual(updated_photo.description, 'Staff updated')
|
||||
|
||||
def test_update_photo_by_non_uploader_denied(self):
|
||||
"""
|
||||
Test — korisnik koji nije uploader niti staff ne može ažurirati.
|
||||
"""
|
||||
image = create_test_image(filename="denied_update.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
update_data = {'description': 'Hacker attempt'}
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.update_photo(
|
||||
instance=photo,
|
||||
data=update_data,
|
||||
user=self.other_user
|
||||
)
|
||||
|
||||
def test_update_photo_without_user_denied(self):
|
||||
"""
|
||||
Test — ažuriranje bez korisnika (user=None) baca ValidationError.
|
||||
"""
|
||||
image = create_test_image(filename="no_user_update.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
update_data = {'description': 'No auth'}
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.update_photo(
|
||||
instance=photo,
|
||||
data=update_data,
|
||||
user=None
|
||||
)
|
||||
|
||||
def test_delete_photo_by_uploader_success(self):
|
||||
"""
|
||||
Test soft-delete fotografije od strane korisnika koji ju je uploadao.
|
||||
"""
|
||||
image = create_test_image(filename="delete.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="To delete",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
self.assertTrue(photo.is_active)
|
||||
|
||||
deleted_photo = VehicleServicePhotoService.delete_photo(
|
||||
instance=photo,
|
||||
user=self.uploader
|
||||
)
|
||||
|
||||
self.assertFalse(deleted_photo.is_active)
|
||||
photo.refresh_from_db()
|
||||
self.assertFalse(photo.is_active)
|
||||
|
||||
def test_delete_photo_by_staff_success(self):
|
||||
"""
|
||||
Test soft-delete od strane staff korisnika.
|
||||
"""
|
||||
image = create_test_image(filename="staff_delete.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="To delete",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
VehicleServicePhotoService.delete_photo(
|
||||
instance=photo,
|
||||
user=self.staff_user
|
||||
)
|
||||
|
||||
photo.refresh_from_db()
|
||||
self.assertFalse(photo.is_active)
|
||||
|
||||
def test_delete_photo_by_non_uploader_denied(self):
|
||||
"""
|
||||
Test — korisnik koji nije uploader niti staff ne može obrisati.
|
||||
"""
|
||||
image = create_test_image(filename="denied_delete.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.delete_photo(
|
||||
instance=photo,
|
||||
user=self.other_user
|
||||
)
|
||||
|
||||
# Fotografija je i dalje aktivna
|
||||
photo.refresh_from_db()
|
||||
self.assertTrue(photo.is_active)
|
||||
|
||||
def test_delete_photo_without_user_denied(self):
|
||||
"""
|
||||
Test — brisanje bez korisnika (user=None) baca ValidationError.
|
||||
"""
|
||||
image = create_test_image(filename="no_user_delete.jpg")
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
with self.assertRaises(DRFValidationError):
|
||||
VehicleServicePhotoService.delete_photo(
|
||||
instance=photo,
|
||||
user=None
|
||||
)
|
||||
|
||||
photo.refresh_from_db()
|
||||
self.assertTrue(photo.is_active)
|
||||
|
||||
def test_update_photo_does_not_allow_image_field_update(self):
|
||||
"""
|
||||
Test — update ne dozvoljava ponovno upload slike (ili je ignorirano).
|
||||
Sadržaj image polja se ne mijenja ako je proslijeđen u update_data.
|
||||
"""
|
||||
image1 = create_test_image(filename="original.jpg", color='red')
|
||||
photo = VehicleServicePhoto.objects.create(
|
||||
service_record=self.service_record,
|
||||
image=image1,
|
||||
description="Original",
|
||||
uploaded_by=self.uploader
|
||||
)
|
||||
|
||||
original_image_name = photo.image.name
|
||||
|
||||
# Pokušaj update s novom slikom
|
||||
image2 = create_test_image(filename="new.jpg", color='blue')
|
||||
update_data = {
|
||||
'description': 'Updated',
|
||||
'image': image2
|
||||
}
|
||||
|
||||
VehicleServicePhotoService.update_photo(
|
||||
instance=photo,
|
||||
data=update_data,
|
||||
user=self.uploader
|
||||
)
|
||||
|
||||
photo.refresh_from_db()
|
||||
# image bi trebao biti preskočen (ne ažurira se)
|
||||
self.assertEqual(photo.image.name, original_image_name)
|
||||
self.assertEqual(photo.description, 'Updated')
|
||||
|
||||
def test_multiple_photos_for_same_service_record(self):
|
||||
"""
|
||||
Test — isti servisni zapis može imati više fotografija.
|
||||
"""
|
||||
image1 = create_test_image(filename="photo1.jpg", color='red')
|
||||
image2 = create_test_image(filename="photo2.jpg", color='blue')
|
||||
image3 = create_test_image(filename="photo3.jpg", color='green')
|
||||
|
||||
data1 = {'service_record': self.service_record, 'image': image1, 'description': 'Photo 1'}
|
||||
data2 = {'service_record': self.service_record, 'image': image2, 'description': 'Photo 2'}
|
||||
data3 = {'service_record': self.service_record, 'image': image3, 'description': 'Photo 3'}
|
||||
|
||||
photo1 = VehicleServicePhotoService.upload_photo(data=data1, uploaded_by=self.uploader)
|
||||
photo2 = VehicleServicePhotoService.upload_photo(data=data2, uploaded_by=self.uploader)
|
||||
photo3 = VehicleServicePhotoService.upload_photo(data=data3, uploaded_by=self.other_user)
|
||||
|
||||
photos = VehicleServicePhoto.objects.filter(service_record=self.service_record, is_active=True)
|
||||
self.assertEqual(photos.count(), 3)
|
||||
|
||||
# Provjeri da su sve aktivne
|
||||
for photo in [photo1, photo2, photo3]:
|
||||
self.assertTrue(photo.is_active)
|
||||
88
backend/modules/fleet/tests/test_pusher_service.py
Normal file
88
backend/modules/fleet/tests/test_pusher_service.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# backend/modules/fleet/tests/test_pusher_service.py
|
||||
from django.test import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
import uuid
|
||||
|
||||
from infrastructure.pusher_service import PusherService
|
||||
|
||||
|
||||
class PusherServiceTests(TestCase):
|
||||
@patch("infrastructure.pusher_service.PusherService.get_client")
|
||||
@patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
|
||||
def test_broadcast_notification_success(self, _is_available, mock_get_client):
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client.trigger.return_value = {"message": "ok"}
|
||||
|
||||
ok = PusherService.broadcast_notification(
|
||||
user_id=42,
|
||||
title="Test naslov",
|
||||
message="Test poruka",
|
||||
level="info",
|
||||
notification_id=123,
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
mock_client.trigger.assert_called_once()
|
||||
_, kwargs = mock_client.trigger.call_args
|
||||
self.assertEqual(kwargs["channels"], "private-user-42")
|
||||
self.assertEqual(kwargs["event_name"], "vehicle.notification.created")
|
||||
self.assertEqual(kwargs["data"]["id"], 123)
|
||||
|
||||
@patch("infrastructure.pusher_service.PusherService.get_client")
|
||||
@patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
|
||||
def test_authenticate_channel_allows_own_private_channel(self, _is_available, mock_get_client):
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client.authenticate.return_value = {"auth": "signed-token"}
|
||||
|
||||
auth_data = PusherService.authenticate_channel(
|
||||
socket_id="123.456",
|
||||
channel_name="private-user-7",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertEqual(auth_data, {"auth": "signed-token"})
|
||||
mock_client.authenticate.assert_called_once_with(
|
||||
channel="private-user-7",
|
||||
socket_id="123.456",
|
||||
)
|
||||
|
||||
@patch("infrastructure.pusher_service.PusherService.get_client")
|
||||
@patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
|
||||
def test_authenticate_channel_rejects_other_users_channel(self, _is_available, mock_get_client):
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
auth_data = PusherService.authenticate_channel(
|
||||
socket_id="123.456",
|
||||
channel_name="private-user-999",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertIsNone(auth_data)
|
||||
mock_client.authenticate.assert_not_called()
|
||||
|
||||
@patch("infrastructure.pusher_service.PusherService.get_client")
|
||||
@patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
|
||||
def test_broadcast_notification_serializes_uuid_values(self, _is_available, mock_get_client):
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client.trigger.return_value = {"message": "ok"}
|
||||
|
||||
notification_id = uuid.uuid4()
|
||||
metadata_uuid = uuid.uuid4()
|
||||
|
||||
ok = PusherService.broadcast_notification(
|
||||
user_id=10,
|
||||
title="UUID test",
|
||||
message="UUID payload",
|
||||
level="info",
|
||||
notification_id=notification_id,
|
||||
metadata={"entity_id": metadata_uuid},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
_, kwargs = mock_client.trigger.call_args
|
||||
self.assertEqual(kwargs["data"]["id"], str(notification_id))
|
||||
self.assertEqual(kwargs["data"]["metadata"]["entity_id"], str(metadata_uuid))
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from django.db import models
|
||||
from modules.fleet.models import Vehicle, VehicleServiceRecord
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_total_cost_calculation(test_user):
|
||||
# Kreiraj vozilo prema tvom modelu
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-001")
|
||||
|
||||
VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=100.00, mileage=5000, description="Servis 1")
|
||||
VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=50.50, mileage=5200, description="Servis 2")
|
||||
|
||||
# Agregacija kroz tvoj related_name='service_records'
|
||||
total = vehicle.service_records.aggregate(models.Sum('cost'))['cost__sum']
|
||||
|
||||
# Decimal field vraća Decimal, pa uspoređujemo s float-om ili Decimal-om
|
||||
assert float(total) == 150.50
|
||||
22
backend/modules/fleet/tests/test_service_photo_upload_to.py
Normal file
22
backend/modules/fleet/tests/test_service_photo_upload_to.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from modules.fleet.models import _service_photo_upload_to
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_service_photo_upload_path_fallback():
|
||||
"""
|
||||
Testira fallback mehanizam: ako instanca nema service_record,
|
||||
putanja mora sadržavati 'unknown'.
|
||||
"""
|
||||
# Simuliramo instancu koja nema 'service_record' ili baca grešku
|
||||
instance = MagicMock()
|
||||
del instance.service_record # Simuliramo nedostajući atribut
|
||||
|
||||
filename = "test_kvar.jpg"
|
||||
|
||||
# Pozivamo funkciju
|
||||
path = _service_photo_upload_to(instance, filename)
|
||||
|
||||
# Očekujemo da putanja sadrži 'unknown' kao fallback
|
||||
assert "fleet/unknown/service_photos/" in path
|
||||
assert filename in path # uuid je prefiks, pa provjeravamo da li je filename na kraju
|
||||
52
backend/modules/fleet/tests/test_services.py
Normal file
52
backend/modules/fleet/tests/test_services.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from modules.fleet.services import VehicleService
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
|
||||
|
||||
|
||||
class FleetServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="tech2", email=f"tech2+{uuid.uuid4()}@example.test", password="pass")
|
||||
self.vehicle = Vehicle.objects.create(
|
||||
registration_number=f"RI-{uuid.uuid4().hex[:6].upper()}",
|
||||
make="VW",
|
||||
model="Polo",
|
||||
current_mileage=5000,
|
||||
service_interval_km=10000,
|
||||
assigned_servicer=self.user
|
||||
)
|
||||
|
||||
def test_create_work_order_via_service(self):
|
||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
|
||||
wo = VehicleService.create_work_order(data)
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(self.vehicle.current_mileage, 5200)
|
||||
self.assertEqual(wo.distance, 200)
|
||||
|
||||
def test_create_service_record_via_service(self):
|
||||
data = {"vehicle": self.vehicle, "performed_by": self.user, "description": "Brake pads", "mileage": 5200, "cost": "120.00"}
|
||||
rec = VehicleService.create_service_record(data)
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(rec.next_service_due_at, 5200 + self.vehicle.service_interval_km)
|
||||
self.assertEqual(self.vehicle.current_mileage, 5200)
|
||||
|
||||
def test_vehicle_next_service_at_logic(self):
|
||||
# current=12000, interval=15000 -> blocks=(12000//15000)+1 = 1 -> 1*15000 = 15000
|
||||
v = Vehicle.objects.create(current_mileage=12000, service_interval_km=15000)
|
||||
assert v.next_service_at() == 15000
|
||||
|
||||
# current=16000, interval=15000 -> blocks=(16000//15000)+1 = 2 -> 2*15000 = 30000
|
||||
v.current_mileage = 16000
|
||||
assert v.next_service_at() == 30000
|
||||
|
||||
def test_work_order_distance_property(self):
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-004")
|
||||
# Nalog bez završne kilometraže
|
||||
wo = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, start_mileage=1000)
|
||||
assert wo.distance is None
|
||||
|
||||
# Nalog sa završnom kilometražom
|
||||
wo.end_mileage = 1100
|
||||
assert wo.distance == 100
|
||||
30
backend/modules/fleet/tests/trest_travel_expenses.py
Normal file
30
backend/modules/fleet/tests/trest_travel_expenses.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_work_order_updates_vehicle_mileage():
|
||||
# 1. Kreiraj vozilo (koristeći tvoj model 'Vehicle')
|
||||
vehicle = Vehicle.objects.create(
|
||||
registration_number="ZG-123-ABC",
|
||||
current_mileage=5000
|
||||
)
|
||||
|
||||
# 2. Kreiraj radni/putni nalog
|
||||
# Pretpostavljam da ti treba i 'creator' zbog ForeignKey relacije
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
creator = User.objects.create_user(username="testuser")
|
||||
|
||||
order = WorkOrder.objects.create(
|
||||
vehicle=vehicle,
|
||||
creator=creator,
|
||||
start_mileage=5000,
|
||||
end_mileage=5500,
|
||||
purpose="Delivery"
|
||||
)
|
||||
|
||||
# 3. Nalog je već pozvao save() pri kreiranju,
|
||||
# pa bi vozilo trebalo biti ažurirano
|
||||
vehicle.refresh_from_db()
|
||||
assert vehicle.current_mileage == 5500
|
||||
22
backend/modules/fleet/urls.py
Normal file
22
backend/modules/fleet/urls.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
||||
WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'vehicles', VehicleViewSet, basename='vehicle')
|
||||
router.register(r'cranes', CraneViewSet, basename='crane')
|
||||
router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
|
||||
router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
|
||||
router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
|
||||
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
|
||||
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
|
||||
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
urlpatterns += [
|
||||
path('pusher-auth/', pusher_auth, name='pusher-auth'),
|
||||
]
|
||||
1563
backend/modules/fleet/views.py
Normal file
1563
backend/modules/fleet/views.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user