prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

View File

View 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 ""

View 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"

View 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'],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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'),
),
]

View 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',),
),
]

View 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'),
),
]

View File

@@ -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),
]

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
),
]

View File

@@ -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',
),
),
]

View 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'],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View 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})"

View 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)

View 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)

View 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,
}

View 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

View 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

View 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
"", # 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)

View 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)

View 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

View 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)

View 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())

View 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)

View 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))

View File

@@ -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

View 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

View 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

View 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

View 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'),
]

File diff suppressed because it is too large Load Diff