interaktivna Tablica radnih sati, db
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-13 00:36:28 +02:00
parent 70151f1225
commit 3ad536a23e
22 changed files with 1629 additions and 174 deletions

View File

@@ -1,21 +1,56 @@
# backend/modules/task_management/admin.py
# backend/modules/task_management/admin.py
from django.contrib import admin
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline):
model = TaskWorkEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'template_entry')
readonly_fields = ('template_entry',)
class TaskWorkHoursTableInline(admin.StackedInline):
model = TaskWorkHoursTable
extra = 0
fields = ('data', 'is_active')
class TaskTemplateEntryInline(admin.TabularInline):
model = TaskTemplateEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'is_active')
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
"""
Konfiguracija prikaza modela u Admin panelu.
"""
# Koja polja želimo vidjeti u tabličnom prikazu
list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at')
# Omogućuje filtriranje s desne strane
list_display = ('title', 'status', 'assigned_to', 'vehicle', 'work_order', 'created_at')
list_filter = ('status', 'created_at', 'work_order')
# Omogućuje pretraživanje po tekstu
search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
# Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati)
readonly_fields = ('id', 'created_at', 'updated_at')
readonly_fields = ('id', 'created_at', 'updated_at')
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)
@admin.register(TaskTemplate)
class TaskTemplateAdmin(admin.ModelAdmin):
list_display = ('code', 'title', 'status', 'is_active', 'created_at')
list_filter = ('status', 'is_active', 'created_at')
search_fields = ('code', 'title', 'description')
inlines = (TaskTemplateEntryInline,)
@admin.register(TaskWorkEntry)
class TaskWorkEntryAdmin(admin.ModelAdmin):
list_display = ('task', 'sort_order', 'title', 'template_entry', 'created_at')
list_filter = ('created_at',)
search_fields = ('task__title', 'title', 'active_faults', 'explanation_hr')
readonly_fields = ('id', 'created_at', 'updated_at')
@admin.register(TaskWorkHoursTable)
class TaskWorkHoursTableAdmin(admin.ModelAdmin):
list_display = ('task', 'updated_at', 'is_active')
search_fields = ('task__title', 'task__vehicle__registration_number')
readonly_fields = ('id', 'created_at', 'updated_at')

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.15 on 2026-07-12
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0003_task_vehicle'),
]
operations = [
migrations.CreateModel(
name='TaskTemplate',
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')),
('code', models.SlugField(max_length=64, unique=True)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20)),
],
options={
'ordering': ['title'],
},
),
migrations.CreateModel(
name='TaskTemplateEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True, help_text='Aktivne greške.')),
('explanation_hr', models.TextField(blank=True, help_text='Obrazloženje na hrvatskom jeziku.')),
('task_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='task_management.tasktemplate')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.CreateModel(
name='TaskWorkEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True)),
('explanation_hr', models.TextField(blank=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_entries', to='task_management.task')),
('template_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_work_entries', to='task_management.tasktemplateentry')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.AddConstraint(
model_name='tasktemplateentry',
constraint=models.UniqueConstraint(fields=('task_template', 'sort_order'), name='task_template_entry_sort_order_unique'),
),
]

View File

@@ -0,0 +1,100 @@
# Generated by Django 5.2.15 on 2026-07-12
from django.db import migrations
def seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplateEntry = apps.get_model('task_management', 'TaskTemplateEntry')
templates = [
{
'code': 'zamjena-ulja-500h',
'title': 'Zamjena ulja nakon 500h',
'description': 'Redovni servis uljnog sustava nakon 500 radnih sati.',
'status': 'aktivan',
'entries': [
('Provjera razine i stanja ulja', 'Izvršena kontrola razine i vizualna kontrola kontaminacije ulja.', 'Nema aktivnih grešaka.', 'Sustav uredan, bez upozorenja.'),
('Ispuštanje starog ulja', 'Sigurno ispušteno staro ulje prema servisnoj proceduri.', 'Nema aktivnih grešaka.', 'Ulje zamijenjeno prema uputama proizvođača.'),
('Zamjena filtera ulja', 'Demontiran stari i ugrađen novi filter ulja.', 'Nema aktivnih grešaka.', 'Filter promijenjen i sustav pripremljen za punjenje.'),
('Punjenje i test sustava', 'Sustav napunjen novim uljem i odrađen probni rad.', 'Nema aktivnih grešaka.', 'Nakon testa nema curenja ni grešaka.'),
],
},
{
'code': 'zamjena-ulja-1000h',
'title': 'Zamjena ulja nakon 1000h',
'description': 'Veliki servis uljnog sustava nakon 1000 radnih sati.',
'status': 'aktivan',
'entries': [
('Dijagnostika sustava prije servisa', 'Očitani parametri rada i zabilježeno početno stanje.', 'Provjeriti povremeni pad tlaka.', 'Prijevod poruke greške i potvrda simptoma na hrvatskom.'),
('Zamjena ulja i glavnih filtera', 'Zamijenjeno ulje i svi pripadajući filter elementi.', 'Nema kritičnih aktivnih grešaka.', 'Servisni interval resetiran.'),
('Kontrola brtvi i spojeva', 'Pregledani spojevi i brtve na mogućnost curenja.', 'Nema aktivnih grešaka.', 'Spojevi su suhi i ispravni.'),
('Test pod opterećenjem', 'Izvršen funkcionalni test pod radnim opterećenjem.', 'Nema aktivnih grešaka.', 'Sustav stabilan pri opterećenju.'),
],
},
{
'code': 'promjena-senzora',
'title': 'Promjena senzora',
'description': 'Zamjena neispravnog senzora i verifikacija rada.',
'status': 'aktivan',
'entries': [
('Identifikacija neispravnog senzora', 'Dijagnostikom potvrđena neispravnost senzora.', 'Aktivna greška senzora.', 'Greška prevedena s engleskog i objašnjena korisniku.'),
('Demontaža i montaža novog senzora', 'Neispravan senzor uklonjen i ugrađen novi originalni dio.', 'Nema novih aktivnih grešaka.', 'Montaža izvedena prema specifikaciji.'),
('Kalibracija senzora', 'Izvršena kalibracija i sinkronizacija s upravljačkom jedinicom.', 'Nema aktivnih grešaka.', 'Kalibracija uspješna.'),
('Završno testiranje', 'Provjeren odziv sustava tijekom rada.', 'Nema aktivnih grešaka.', 'Senzor radi unutar tolerancije.'),
],
},
{
'code': 'aktivna-osovina-ukoso',
'title': 'Aktivna osovina ide ukoso',
'description': 'Dijagnostika i otklanjanje kvara aktivne osovine.',
'status': 'aktivan',
'entries': [
('Dijagnostika geometrije osovine', 'Provjereni kutovi i odstupanja geometrije osovine.', 'Aktivna greška upravljanja osovinom.', 'Opis greške preveden i pojašnjen na hrvatskom.'),
('Pregled hidrauličkih komponenti', 'Provjeren tlak i stanje hidrauličkih vodova.', 'Mogući pad tlaka na upravljačkom krugu.', 'Predložena zamjena kritičnog ventila.'),
('Korekcija i podešavanje', 'Izvršeno mehaničko/hidrauličko podešavanje osovine.', 'Nema aktivnih grešaka nakon podešavanja.', 'Osovina vraćena u radne tolerancije.'),
('Probna vožnja i završna potvrda', 'Izvršena probna vožnja i kontrola stabilnosti pravca.', 'Nema aktivnih grešaka.', 'Kvar otklonjen, vozilo spremno za rad.'),
],
},
]
for template_data in templates:
entries = template_data.pop('entries')
template, _ = TaskTemplate.objects.update_or_create(
code=template_data['code'],
defaults=template_data,
)
TaskTemplateEntry.objects.filter(task_template=template).delete()
for idx, entry in enumerate(entries, start=1):
title, work_description, active_faults, explanation_hr = entry
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=idx,
title=title,
work_description=work_description,
active_faults=active_faults,
explanation_hr=explanation_hr,
)
def reverse_seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplate.objects.filter(
code__in=[
'zamjena-ulja-500h',
'zamjena-ulja-1000h',
'promjena-senzora',
'aktivna-osovina-ukoso',
]
).delete()
class Migration(migrations.Migration):
dependencies = [
('task_management', '0004_tasktemplate_taskworkentry'),
]
operations = [
migrations.RunPython(seed_task_templates, reverse_seed_task_templates),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0005_seed_task_templates'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,29 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0006_add_finished_task_status'),
]
operations = [
migrations.CreateModel(
name='TaskWorkHoursTable',
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')),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice radnih sati')),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_hours_table', to='task_management.task', verbose_name='Radni zadatak')),
],
options={
'verbose_name': 'Tablica radnih sati',
'verbose_name_plural': 'Tablice radnih sati',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -1,10 +1,11 @@
# backend/modules/task_management/models.py
# backend/modules/task_management/models.py
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 core.base_models import BaseModel # Uvozimo naš Base
from core.base_models import BaseModel
class Task(BaseModel):
"""
@@ -13,6 +14,7 @@ class Task(BaseModel):
STATUS_CHOICES = [
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
]
title = models.CharField(max_length=255)
@@ -23,9 +25,9 @@ class Task(BaseModel):
default='aktivan'
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='tasks'
)
vehicle = models.ForeignKey(
@@ -53,7 +55,102 @@ class Task(BaseModel):
def clean(self):
super().clean()
if self.status == 'neaktivan' and not self.work_order_id:
if self.status in {'neaktivan', 'zavrsen'} and not self.work_order_id:
raise ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
})
class TaskWorkHoursTable(BaseModel):
"""Interaktivna tablica radnih sati povezana s radnim zadatkom."""
task = models.OneToOneField(
Task,
on_delete=models.CASCADE,
related_name='work_hours_table',
verbose_name=_("Radni zadatak"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice radnih sati"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica radnih sati")
verbose_name_plural = _("Tablice radnih sati")
def __str__(self):
return f"Work hours table for task {self.task_id}"
class TaskTemplate(BaseModel):
"""Predložak za brzo kreiranje servisnog radnog zadatka."""
code = models.SlugField(max_length=64, unique=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=Task.STATUS_CHOICES, default='aktivan')
class Meta:
ordering = ['title']
def __str__(self):
return f"{self.title} ({self.code})"
class TaskTemplateEntry(BaseModel):
"""Predlošci radnih unosa koji se generiraju prilikom kreiranja Taska."""
task_template = models.ForeignKey(
TaskTemplate,
on_delete=models.CASCADE,
related_name='entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True, help_text=_("Aktivne greške."))
explanation_hr = models.TextField(blank=True, help_text=_("Obrazloženje na hrvatskom jeziku."))
class Meta:
ordering = ['sort_order', 'created_at']
constraints = [
models.UniqueConstraint(
fields=['task_template', 'sort_order'],
name='task_template_entry_sort_order_unique',
)
]
def __str__(self):
return f"{self.task_template.code} #{self.sort_order} - {self.title}"
class TaskWorkEntry(BaseModel):
"""Radni unos na konkretnom Tasku (generiran iz templatea ili ručno)."""
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name='work_entries',
)
template_entry = models.ForeignKey(
TaskTemplateEntry,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='task_work_entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True)
explanation_hr = models.TextField(blank=True)
class Meta:
ordering = ['sort_order', 'created_at']
def __str__(self):
return f"{self.task_id} #{self.sort_order} - {self.title}"

View File

@@ -1,12 +1,92 @@
# backend/modules/task_management/serializers.py
# backend/modules/task_management/serializers.py
from rest_framework import serializers
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
WORK_HOURS_ROW_FIELDS = [
'day',
'date',
'work_time_from',
'work_time_to',
'travel_time_from',
'travel_time_to',
'break_hours',
'work_hours',
'travel_hours',
'departure_place',
'arrival_place',
'vehicle_km',
]
class TaskWorkHoursTableSerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkHoursTable
fields = ['id', 'task', 'data', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
def _normalize_row(self, row):
normalized = {}
for field in WORK_HOURS_ROW_FIELDS:
value = row.get(field, '')
normalized[field] = '' if value is None else str(value).strip()
return normalized
def validate_data(self, value):
if value is None:
return None
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt ili null.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:30]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
class TaskWorkEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkEntry
fields = [
'id', 'task', 'template_entry', 'sort_order', 'title',
'work_description', 'active_faults', 'explanation_hr',
'created_at',
]
read_only_fields = ['id', 'created_at']
class TaskTemplateEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskTemplateEntry
fields = [
'id', 'sort_order', 'title', 'work_description',
'active_faults', 'explanation_hr',
]
read_only_fields = ['id']
class TaskTemplateSerializer(serializers.ModelSerializer):
entries = TaskTemplateEntrySerializer(many=True, read_only=True)
class Meta:
model = TaskTemplate
fields = ['id', 'code', 'title', 'description', 'status', 'entries']
read_only_fields = ['id']
class TaskSerializer(serializers.ModelSerializer):
assigned_to_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
class Meta:
model = Task
@@ -15,27 +95,48 @@ class TaskSerializer(serializers.ModelSerializer):
'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration',
'work_order', 'work_order_label',
'template_id',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'title': {'required': False, 'allow_blank': True},
'description': {'required': False, 'allow_blank': True},
'status': {'required': False},
'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True},
}
def validate_title(self, value):
"""Primjer custom validacije: naziv ne smije biti prekratak."""
if not value:
return value
if len(value) < 3:
raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.")
return value
def validate(self, attrs):
current = getattr(self, 'instance', None)
template_id = attrs.get('template_id')
status_value = attrs.get('status', getattr(current, 'status', 'aktivan'))
work_order = attrs.get('work_order', getattr(current, 'work_order', None))
vehicle = attrs.get('vehicle', getattr(current, 'vehicle', None))
title = (attrs.get('title', getattr(current, 'title', '')) or '').strip()
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).first()
if not template:
raise serializers.ValidationError({'template_id': 'Odabrani template ne postoji ili nije aktivan.'})
if current is None and not vehicle:
raise serializers.ValidationError({
'vehicle': "Dizalica je obavezna pri kreiranju taska."
})
if current is None and not template_id and not title:
raise serializers.ValidationError({
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
})
if work_order and not vehicle:
vehicle = work_order.vehicle
attrs['vehicle'] = vehicle
@@ -44,7 +145,7 @@ class TaskSerializer(serializers.ModelSerializer):
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
})
if status_value == 'neaktivan' and not work_order:
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
raise serializers.ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
@@ -72,4 +173,4 @@ class TaskSerializer(serializers.ModelSerializer):
def get_vehicle_registration(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.registration_number
return obj.vehicle.registration_number

View File

@@ -1,26 +1,45 @@
# backend/modules/task_management/services.py
# backend/modules/task_management/services.py
from .models import Task
from django.db import transaction
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
import logging
from .models import Task, TaskTemplate, TaskWorkEntry
from modules.fleet.models import VehicleServiceRecord
logger = logging.getLogger(__name__)
class TaskService:
@staticmethod
def create_task_entry(data: dict, user=None) -> Task:
"""
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
"""
# Poslovna pravila
if data.get('status') == 'DONE' and (user and not user.is_staff):
payload = dict(data)
template_id = payload.pop('template_id', None)
if payload.get('status') == 'DONE' and (user and not user.is_staff):
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
payload = dict(data)
template = None
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first()
if not template:
raise ValidationError({"template_id": "Odabrani template nije pronađen."})
payload['title'] = template.title
payload['description'] = template.description
payload['status'] = 'aktivan'
payload.setdefault('work_order', None)
title = (payload.get('title') or '').strip()
if title and len(title) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
if not title:
raise ValidationError({"title": "Naslov zadatka je obavezan."})
if user and 'assigned_to' not in payload:
payload['assigned_to'] = user
@@ -30,18 +49,70 @@ class TaskService:
except DjangoValidationError as exc:
logger.error(f"Neuspješna validacija zadatka: {exc}")
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
entry.save()
with transaction.atomic():
entry.save()
if template:
TaskService._create_work_entries_from_template(task=entry, template=template)
TaskService._create_service_records_from_template(task=entry, template=template, user=user)
logger.info(f"Zadatak uspješno kreiran: {entry.id}")
return entry
@staticmethod
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for idx, source in enumerate(template_entries, start=1):
TaskWorkEntry.objects.create(
task=task,
template_entry=source,
sort_order=idx,
title=source.title,
work_description=source.work_description,
active_faults=source.active_faults,
explanation_hr=source.explanation_hr,
)
@staticmethod
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
if not user:
raise ValidationError({
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
})
if not task.vehicle_id:
raise ValidationError({
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
})
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for source in template_entries:
description_parts = []
if source.work_description:
description_parts.append(source.work_description.strip())
if source.explanation_hr:
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
if source.active_faults:
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
VehicleServiceRecord.objects.create(
vehicle=task.vehicle,
task=task,
performed_by=user,
service_title=source.title,
description=description,
parts=(source.active_faults or '').strip(),
cost=0,
)
@staticmethod
def update_task_entry(instance, data, user):
# Validacija za update (npr. samo vlasnik ili admin smije ažurirati)
if not user.is_staff and instance.assigned_to != user:
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
# Spremanje
for attr, value in data.items():
if attr == 'template_id':
continue
setattr(instance, attr, value)
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
@@ -50,4 +121,4 @@ class TaskService:
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
instance.save()
return instance
return instance

View File

@@ -31,4 +31,16 @@ class TaskSerializerTests(TestCase):
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
def test_cannot_mark_finished_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 3", "description": "", "status": "zavrsen", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_can_mark_finished_with_work_order(self):
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Task 4", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors

View File

@@ -2,8 +2,8 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.exceptions import ValidationError
from modules.task_management.services import TaskService
from modules.task_management.models import Task
from modules.fleet.models import Vehicle, WorkOrder
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
class TaskServiceTests(TestCase):
@@ -50,4 +50,84 @@ class TaskServiceTests(TestCase):
user=self.user,
)
assert updated.status == "neaktivan"
assert updated.work_order == work_order
assert updated.work_order == work_order
def test_update_task_entry_rejects_finished_without_work_order(self):
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis')
with self.assertRaises(ValidationError):
TaskService.update_task_entry(instance=task, data={"status": "zavrsen"}, user=self.user)
def test_update_task_entry_allows_finished_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen", "work_order": work_order},
user=self.user,
)
assert updated.status == "zavrsen"
assert updated.work_order == work_order
def test_create_task_entry_from_template_creates_service_records(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-servis-zapisi',
title='Template servis',
description='Opis templatea',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Pregled sustava',
work_description='Provjera hidraulike',
active_faults='Greška E101',
explanation_hr='Prevedeno i objašnjeno.',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=2,
title='Zamjena senzora',
work_description='Zamijenjen senzor kuta',
active_faults='',
explanation_hr='Senzor kalibriran.',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(created.vehicle_id, vehicle.id)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 2)
records = VehicleServiceRecord.objects.filter(task=created).order_by('created_at')
self.assertEqual(records.count(), 2)
self.assertEqual(records.first().service_title, 'Pregled sustava')
self.assertIn('Obrazloženje (HR): Prevedeno i objašnjeno.', records.first().description)
self.assertIn('Aktivne greške: Greška E101', records.first().description)
self.assertEqual(records.first().performed_by_id, self.user.id)
def test_create_task_entry_from_template_limits_service_records_to_ten(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-03", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-limit-10',
title='Template limit',
description='Opis',
status='aktivan',
)
for index in range(1, 13):
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=index,
title=f'Korak {index}',
work_description=f'Opis {index}',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)

View File

@@ -1,10 +1,11 @@
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from rest_framework.test import APIClient
from modules.task_management.views import TaskViewSet
from modules.task_management.serializers import TaskSerializer
from modules.task_management.models import Task
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry
from modules.fleet.models import Vehicle
@@ -17,6 +18,8 @@ class TaskViewSetTests(TestCase):
self.view.request = self.factory.post("/")
self.view.request.user = self.user
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
self.api_client = APIClient()
self.api_client.force_authenticate(user=self.user)
@patch("modules.task_management.views.TaskService.create_task_entry")
def test_perform_create_calls_service(self, mock_create):
@@ -53,4 +56,32 @@ class TaskViewSetTests(TestCase):
task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user)
self.view.perform_destroy(task)
task.refresh_from_db()
assert task.is_active is False
assert task.is_active is False
def test_template_created_task_is_visible_in_service_records_endpoint(self):
template = TaskTemplate.objects.create(
code='template-api-service-records',
title='Template API',
description='Opis',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Korak API',
work_description='Opis API koraka',
)
response = self.api_client.post(
"/api/tasks/tasks/",
data={"template_id": str(template.id), "vehicle": str(self.vehicle.id)},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
created_task_id = str(response.json()["id"])
service_records_response = self.api_client.get("/api/fleet/service-records/")
self.assertEqual(service_records_response.status_code, 200, service_records_response.content)
payload = service_records_response.json()
rows = payload if isinstance(payload, list) else payload.get("results", [])
self.assertTrue(any(str(row.get("task") or "") == created_task_id for row in rows))

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.task_management.models import Task, TaskWorkHoursTable
class TaskWorkHoursTableTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="wh-user", email="wh-user@example.test", password="Pass123!abc")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(registration_number="ZG-WH-01", asset_type='crane')
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
location='Zagreb',
)
self.task = Task.objects.create(
title="Task tablica sati",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
)
def test_put_work_hours_table_accepts_null_data(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data=None,
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json().get('data'))
def test_put_work_hours_table_accepts_partial_row_and_normalizes(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data={"rows": [{"day": "NED", "travel_hours": "2,5", "departure_place": "Zagreb"}]},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('day'), 'NED')
self.assertEqual(rows[0].get('travel_hours'), '2,5')
self.assertEqual(rows[0].get('departure_place'), 'Zagreb')
self.assertIn('arrival_place', rows[0])
self.assertEqual(rows[0].get('arrival_place'), '')
def test_put_work_hours_table_rejects_invalid_json_shape(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data="invalid-shape",
format="json",
)
self.assertEqual(response.status_code, 400, response.content)
def test_task_service_context_includes_work_hours_table_data(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={"rows": [{"day": "NED", "date": "12.07.2026"}]},
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/task-service-context/")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
tasks = payload.get('tasks', [])
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0].get('work_hours_table'), {"rows": [{"day": "NED", "date": "12.07.2026"}]})
def test_service_records_pdf_works_with_empty_work_hours_table(self):
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response["Content-Type"], "application/pdf")

View File

@@ -1,15 +1,18 @@
# backend/modules/task_management/views.py
# backend/modules/task_management/views.py
import logging
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import Task
from .serializers import TaskSerializer
from .models import Task, TaskTemplate
from .serializers import TaskSerializer, TaskTemplateSerializer, TaskWorkHoursTableSerializer
from .services import TaskService
# Postavljanje loggera za modul
logger = logging.getLogger(__name__)
class TaskViewSet(viewsets.ModelViewSet):
"""
ViewSet koji koristi TaskService za rukovanje operacijama.
@@ -19,7 +22,7 @@ class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = Task.objects.filter(is_active=True).select_related(
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle'
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle', 'work_hours_table'
)
vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id:
@@ -27,38 +30,60 @@ class TaskViewSet(viewsets.ModelViewSet):
return qs
def perform_create(self, serializer):
"""
Umjesto direktnog serializer.save(), pozivamo servis.
Proslijeđujemo validirane podatke (dict) i korisnika.
"""
try:
entry = TaskService.create_task_entry(
data=serializer.validated_data,
user=self.request.user)
user=self.request.user,
)
serializer.instance = entry
except ValidationError as e:
# Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request
logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}")
raise e
def perform_update(self, serializer):
"""
Servis obavlja biznis logiku (validaciju/modifikaciju),
a serializer.save() finalizira zapis.
"""
# Servis sada samo modificira instancu u memoriji
TaskService.update_task_entry(
instance=serializer.instance,
data=serializer.validated_data,
user=self.request.user
user=self.request.user,
)
# Sada DRF sigurno sprema izmijenjenu instancu
serializer.save()
def perform_destroy(self, instance):
"""
Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
"""
instance.is_active = False
instance.save()
logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}")
@action(detail=False, methods=['get'], url_path='templates')
def templates(self, request):
templates = TaskTemplate.objects.filter(is_active=True).prefetch_related('entries').order_by('title')
serializer = TaskTemplateSerializer(templates, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='work-hours-table')
def work_hours_table(self, request, pk=None):
task = self.get_object()
table = getattr(task, 'work_hours_table', None)
if request.method.lower() == 'get':
if table:
serializer = TaskWorkHoursTableSerializer(table)
return Response(serializer.data)
return Response({'task': str(task.id), 'data': None})
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = None
serializer = TaskWorkHoursTableSerializer(
table,
data={
'task': str(task.id),
'data': payload_data,
},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(task=task)
return Response(TaskWorkHoursTableSerializer(instance).data)