add from v002

This commit is contained in:
2026-05-20 21:24:00 +02:00
parent 1a6ba86d0b
commit 83fb8c69bb
98 changed files with 10878 additions and 0 deletions

View File

View File

@@ -0,0 +1,112 @@
# src/operations/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import RadniNalog, RadniNalogSlika, PutniNalog
# --- INLINES ---
class RadniNalogSlikaInline(admin.TabularInline):
"""Omogućuje dodavanje više slika unutar samog radnog naloga."""
model = RadniNalogSlika
extra = 1
fields = ('slika', 'opis')
class RadniNalogInline(admin.StackedInline):
"""
Omogućuje dodavanje i pregled radnih naloga direktno unutar Putnog naloga.
Ovo je ključno za terenski rad (jedno putovanje -> više odrađenih strojeva).
"""
model = RadniNalog
extra = 0
autocomplete_fields = ['stroj', 'klijent', 'izvrsitelj']
fieldsets = (
(None, {
'fields': (('broj_naloga', 'status'), ('klijent', 'stroj'), 'opis_kvara')
}),
)
# --- ADMIN REGISTRACIJE ---
@admin.register(PutniNalog)
class PutniNalogAdmin(admin.ModelAdmin):
list_display = ('broj_naloga', 'prikaz_vozila', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status_marker')
list_filter = ('status', 'datum_izdavanja', 'vozilo')
search_fields = ('broj_naloga', 'vozilo__registracija', 'korisnik__last_name')
autocomplete_fields = ['vozilo', 'korisnik']
# Inlines omogućuju da vidiš servise (RN) unutar putovanja (PN)
inlines = [RadniNalogInline]
# pocetna_km je readonly jer se automatski povlači iz flote pri kreiranju
readonly_fields = ('pocetna_km',)
fieldsets = (
('Logistika puta', {
'fields': (('broj_naloga', 'status'), ('vozilo', 'korisnik'), 'relacija', 'mjesto_odredista')
}),
('Kilometraža i vrijeme', {
'fields': (('pocetna_km', 'zavrsna_km'), ('vrijeme_polaska', 'vrijeme_povratka')),
'description': 'Završni kilometri će automatski ažurirati bazu vozila po zatvaranju naloga.'
}),
)
def prikaz_vozila(self, obj):
return format_html('<b>{}</b>', obj.vozilo.registracija)
prikaz_vozila.short_description = "Vozilo"
def status_marker(self, obj):
color = 'green' if obj.status == 'ZAVRSEN' else 'orange'
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', color, obj.get_status_display())
status_marker.short_description = "Status"
@admin.register(RadniNalog)
class RadniNalogAdmin(admin.ModelAdmin):
# Prikazujemo stroj i putni nalog kako bismo znali gdje je servis odrađen
list_display = ('prikaz_broja', 'datum_kreiranja', 'klijent', 'stroj', 'status_boja', 'povezani_pn')
list_filter = ('status', 'datum_kreiranja', 'izvrsitelj', 'stroj__tip')
# Pretraga po serijskom broju stroja je ključna za servisere
search_fields = (
'broj_naloga',
'klijent__naziv',
'stroj__naziv',
'stroj__serijski_broj'
)
# Autocomplete za lakše biranje među stotinama strojeva i klijenata
autocomplete_fields = ['klijent', 'stroj', 'putni_nalog', 'izvrsitelj']
inlines = [RadniNalogSlikaInline]
fieldsets = (
('Subjekt servisa', {
'fields': (('broj_naloga', 'status'), ('klijent', 'stroj'), ('izvrsitelj', 'putni_nalog'))
}),
('Opis i dokumentacija', {
'fields': ('opis_kvara', 'slika_kvara', 'potpis_klijenta')
}),
)
def prikaz_broja(self, obj):
return format_html('<b>RN-{}</b>', obj.broj_naloga)
prikaz_broja.short_description = "Nalog"
def povezani_pn(self, obj):
if obj.putni_nalog:
return obj.putni_nalog.broj_naloga
return "Nema (Radiona)"
povezani_pn.short_description = "Putni nalog"
def status_boja(self, obj):
boje = {
'PLANIRANO': '#6b7280',
'U_RADU': '#2563eb',
'ZAVRSENO': '#10b981',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
boje.get(obj.status, 'black'),
obj.get_status_display()
)
status_boja.short_description = "Status"

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class OperationsConfig(AppConfig):
name = 'operations'

View File

@@ -0,0 +1,132 @@
import random
import os
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.core.files import File
from operations.models import RadniNalog, RadniNalogSlika, PutniNalog
from fleet.models import Vozilo, Stroj
from kupci.models import Kupac
from kalendar.services import kreiraj_kalendarski_unos
User = get_user_model()
class Command(BaseCommand):
help = 'Popunjava bazu s Putnim nalozima, Radnim nalozima i slikama'
def add_arguments(self, parser):
parser.add_argument('total_putovanja', type=int, help='Broj putnih naloga', default=3)
parser.add_argument('--img', type=str, default='test_assets/kvar.jpg')
def handle(self, *args, **kwargs):
total_putovanja = kwargs['total_putovanja']
test_img_path = kwargs['img']
kupci = Kupac.objects.prefetch_related('strojevi').all()
vozila = list(Vozilo.objects.all())
serviseri = list(User.objects.filter(is_staff=True))
if not kupci.exists() or not vozila or not serviseri:
self.stdout.write(self.style.ERROR("Baza mora imati kupce, vozila i servisere (is_staff=True)."))
return
# Provjera slike
if not os.path.exists(test_img_path):
self.stdout.write(self.style.WARNING(f"Testna slika {test_img_path} nije pronađena. Generiram bez slika."))
test_img_path = None
opisi_kvarova = [
"Godišnji periodični servis dizalice.",
"Zamjena hidrauličnog crijeva i dopuna ulja.",
"Kalibracija senzora opterećenja.",
"Popravak daljinskog upravljača.",
"Hitna intervencija: Stroj u kvaru."
]
self.stdout.write(f'🚀 Generiranje {total_putovanja} putovanja...')
success_count_rn = 0
trenutna_godina = timezone.now().year
for i in range(total_putovanja):
serviser = random.choice(serviseri)
vozilo = random.choice(vozila)
broj_pn = f"PN-{trenutna_godina}-{PutniNalog.objects.count() + 1:04d}"
# 1. Kreiraj PUTNI NALOG (u statusu OTVOREN)
putni_nalog = PutniNalog.objects.create(
broj_naloga=broj_pn,
vozilo=vozilo,
korisnik=serviser,
relacija="Zagreb - Teren - Zagreb",
mjesto_odredista="Teren",
)
self.stdout.write(self.style.MIGRATE_LABEL(f"Otvoren {broj_pn} (Vozilo: {vozilo.registracija})"))
# 2. Kreiraj 1 do 3 RADNA NALOGA za ovaj putni nalog
za_odraditi = random.randint(1, 3)
kreirani_radni_nalozi = []
for j in range(za_odraditi):
klijenti_sa_strojima = [k for k in kupci if k.strojevi.exists()]
klijent = random.choice(klijenti_sa_strojima)
stroj = random.choice(list(klijent.strojevi.all()))
broj_rn = f"{trenutna_godina}-{RadniNalog.objects.count() + 1:04d}"
try:
nalog = RadniNalog(
broj_naloga=broj_rn,
klijent=klijent,
stroj=stroj,
putni_nalog=putni_nalog,
opis_kvara=random.choice(opisi_kvarova),
izvrsitelj=serviser,
status=RadniNalog.StatusRadaChoices.PLANIRANO
)
if test_img_path:
with open(test_img_path, 'rb') as f:
nalog.slika_kvara.save(f'rn_{broj_rn}.jpg', File(f), save=False)
nalog.save()
kreirani_radni_nalozi.append(nalog)
# Dodatne slike u galeriju
if test_img_path:
for k in range(random.randint(1, 2)):
with open(test_img_path, 'rb') as f:
RadniNalogSlika.objects.create(
radni_nalog=nalog,
slika=File(f, name=f'gal_{broj_rn}_{k}.jpg'),
opis=f"Detalj kvara {k+1}"
)
# 5. Upis u kalendar
kreiraj_kalendarski_unos(nalog)
success_count_rn += 1
self.stdout.write(f" ✅ RN-{broj_rn} dodan u {broj_pn}")
except Exception as e:
self.stderr.write(f" ❌ Greška kod RN-{broj_rn}: {e}")
# 3. NASUMIČNO ZATVARANJE (Simulacija završenog posla)
# 50% šanse da ćemo u skripti odmah "završiti" ovaj put
if random.random() > 0.5:
try:
# Prvo moramo zatvoriti sve radne naloge da bi PN prošao validaciju
for rn in kreirani_radni_nalozi:
rn.status = RadniNalog.StatusRadaChoices.ZAVRSENO
rn.save()
# Dodajemo završne kilometre (uvijek više od početnih)
putni_nalog.zavrsna_km = putni_nalog.pocetna_km + random.randint(50, 500)
putni_nalog.status = PutniNalog.StatusChoices.ZAVRSEN
putni_nalog.save()
self.stdout.write(self.style.SUCCESS(f" 🏁 {broj_pn} automatski ZATVOREN. Kilometri vozila ažurirani."))
except Exception as e:
self.stdout.write(self.style.WARNING(f" ⚠️ PN-{broj_pn} nije zatvoren: {e}"))
self.stdout.write(self.style.SUCCESS(f'\nGotovo! Generirano {total_putovanja} PN i {success_count_rn} RN.'))

View File

@@ -0,0 +1,220 @@
import os
from PIL import Image
import io
from django.db import models, transaction
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
from django.core.exceptions import ValidationError
# --- FUNKCIJE ZA DINAMIČKE PUTANJE ---
def putanja_slike_naloga(instance, filename):
# Kreira putanju: radni-nalozi/broj_naloga/originalni_naziv.ekstenzija
return f'radni-nalozi/{instance.broj_naloga}/{filename}'
def putanja_galerije_naloga(instance, filename):
# instance je objekt RadniNalogSlika koji ima ForeignKey na radni_nalog
# Sprema u isti folder gdje je i glavna slika kvara
return f'radni-nalozi/{instance.radni_nalog.broj_naloga}/galerija/{filename}'
class PutniNalog(models.Model):
class StatusChoices(models.TextChoices):
OTVOREN = 'OTVOREN', 'Otvoren'
ZAVRSEN = 'ZAVRSEN', 'Završen'
broj_naloga = models.CharField(max_length=50, unique=True)
datum_izdavanja = models.DateField(default=timezone.now)
vozilo = models.ForeignKey(
'fleet.Vozilo',
on_delete=models.PROTECT,
related_name='putni_nalozi',
verbose_name="Službeno vozilo"
)
korisnik = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
relacija = models.CharField(max_length=255)
mjesto_odredista = models.CharField(max_length=255)
pocetna_km = models.PositiveIntegerField(
editable=False,
help_text="Automatski preuzeto iz modela Vozilo"
)
zavrsna_km = models.PositiveIntegerField(null=True, blank=True)
vrijeme_polaska = models.DateTimeField(default=timezone.now)
vrijeme_povratka = models.DateTimeField(null=True, blank=True)
status = models.CharField(
max_length=15,
choices=StatusChoices.choices,
default=StatusChoices.OTVOREN
)
class Meta:
verbose_name = "Putni nalog"
verbose_name_plural = "Putni nalozi"
def clean(self):
super().clean()
if self.status == self.StatusChoices.ZAVRSEN:
if self.zavrsna_km is None:
raise ValidationError({'zavrsna_km': "Morate unijeti završne kilometre prije zatvaranja naloga."})
otvoreni_radni_nalozi = self.radni_nalozi.exclude(status='ZAVRSENO').exists()
if otvoreni_radni_nalozi:
raise ValidationError({
'status': "Putni nalog ne može biti završen jer postoje povezani radni nalozi koji još nisu zatvoreni."
})
def save(self, *args, **kwargs):
if not self.pk:
self.pocetna_km = self.vozilo.trenutni_kilometri
self.full_clean()
if self.status == self.StatusChoices.ZAVRSEN and self.zavrsna_km:
with transaction.atomic():
super().save(*args, **kwargs)
v = self.vozilo
v.trenutni_kilometri = self.zavrsna_km
v.pocetni_kilometri = self.zavrsna_km
v.save()
else:
super().save(*args, **kwargs)
def __str__(self):
return f"{self.broj_naloga} - {self.vozilo.registracija}"
class RadniNalog(models.Model):
class StatusRadaChoices(models.TextChoices):
PLANIRANO = 'PLANIRANO', 'Planirano'
U_RADU = 'U_RADU', 'U radu'
ZAVRSENO = 'ZAVRSENO', 'Završeno'
broj_naloga = models.CharField(max_length=20, unique=True, blank=True)
stroj = models.ForeignKey(
'fleet.Stroj',
on_delete=models.CASCADE,
related_name='radni_nalozi',
verbose_name="Stroj na popravku"
)
putni_nalog = models.ForeignKey(
'PutniNalog',
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='radni_nalozi'
)
klijent = models.ForeignKey(
'kupci.Kupac',
on_delete=models.PROTECT,
related_name='radni_nalozi'
)
opis_kvara = models.TextField()
izvrsitelj = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='dodijeljeni_nalozi'
)
status = models.CharField(
max_length=20,
choices=StatusRadaChoices.choices,
default=StatusRadaChoices.PLANIRANO
)
datum_kreiranja = models.DateTimeField(auto_now_add=True, verbose_name="Datum kreiranja")
datum_azuriranja = models.DateTimeField(auto_now=True, verbose_name="Zadnja izmjena")
slika_kvara = models.ImageField(upload_to=putanja_slike_naloga, null=True, blank=True)
potpis_klijenta = models.ImageField(upload_to='potpisi/%Y/%m/', null=True, blank=True)
def save(self, *args, **kwargs):
# 1. AUTOMATSKO GENERIRANJE BROJA NALOGA
if not self.broj_naloga:
godina = timezone.now().year
prefix = f"RN-{godina}-"
# Koristimo atomsku transakciju i select_for_update da spriječimo
# da dva korisnika dobiju isti broj u istoj milisekundi
with transaction.atomic():
zadnji_nalog = RadniNalog.objects.select_for_update().filter(
broj_naloga__startswith=prefix
).order_by('-broj_naloga').first()
if zadnji_nalog:
try:
# RN-2026-0001 -> uzimamo "0001"
zadnji_broj_str = zadnji_nalog.broj_naloga.split('-')[-1]
novi_broj = int(zadnji_broj_str) + 1
except (ValueError, IndexError):
novi_broj = 1
else:
novi_broj = 1
self.broj_naloga = f"{prefix}{novi_broj:04d}"
# 2. OBRADA SLIKE
if self.slika_kvara:
try:
self.slika_kvara = self.rescale_image(self.slika_kvara)
except Exception as e:
print(f"Greška pri obradi slike: {e}")
# 3. PRIMARNO SPREMANJE
super().save(*args, **kwargs)
# 4. LOGIKA ZA PUTNI NALOG
if self.putni_nalog and self.status == self.StatusRadaChoices.ZAVRSENO:
pn = self.putni_nalog
# Provjeravamo ima li nalog još uvijek otvorenih radnih naloga
ostali_otvoreni = pn.radni_nalozi.exclude(
id=self.id, # Isključujemo trenutni nalog jer je on upravo završen
status=self.StatusRadaChoices.ZAVRSENO
).exists()
if not ostali_otvoreni and pn.zavrsna_km:
pn.status = 'ZAVRSEN'
pn.save()
def rescale_image(self, image_field):
img = Image.open(image_field)
if img.height > 1200 or img.width > 1200:
output_size = (1200, 1200)
img.thumbnail(output_size)
img_io = io.BytesIO()
img_format = img.format if img.format else 'JPEG'
img.save(img_io, format=img_format, quality=90)
return ContentFile(img_io.getvalue(), name=image_field.name)
return image_field
class Meta:
verbose_name = "Radni nalog"
verbose_name_plural = "Radni nalozi"
def __str__(self):
return f"RN-{self.broj_naloga} | {self.stroj.naziv}"
class RadniNalogSlika(models.Model):
radni_nalog = models.ForeignKey(
RadniNalog,
related_name='slike',
on_delete=models.CASCADE
)
# POPRAVLJENO: uklonjeni navodnici oko funkcije
slika = models.ImageField(upload_to=putanja_galerije_naloga, verbose_name="Slika")
opis = models.CharField(max_length=100, blank=True, verbose_name="Kratki opis slike")
class Meta:
verbose_name = "Dodatna slika naloga"
verbose_name_plural = "Dodatne slike naloga"
def __str__(self):
return f"Slika za nalog {self.radni_nalog.broj_naloga}"

View File

@@ -0,0 +1,86 @@
from rest_framework import serializers
from .models import RadniNalog, RadniNalogSlika
from kupci.serializers import KupacListaSerializer
from fleet.serializers import VoziloListaSerializer, StrojSerializer
# --- POMOĆNI SERIALIZER ZA SLIKE ---
class RadniNalogSlikaSerializer(serializers.ModelSerializer):
class Meta:
model = RadniNalogSlika
fields = ['id', 'slika', 'opis']
# --- 1. LISTA SERIALIZER ---
class RadniNalogListaSerializer(serializers.ModelSerializer):
klijent_naziv = serializers.CharField(source='klijent.naziv', read_only=True)
stroj_naziv = serializers.CharField(source='stroj.naziv', read_only=True)
izvrsitelj_ime = serializers.CharField(source='izvrsitelj.get_full_name', read_only=True)
class Meta:
model = RadniNalog
fields = [
'id',
'broj_naloga',
'klijent_naziv',
'stroj_naziv',
'status',
'izvrsitelj_ime',
'datum_kreiranja'
]
# --- 2. DETALJI SERIALIZER ---
class RadniNalogDetaljiSerializer(serializers.ModelSerializer):
klijent = KupacListaSerializer(read_only=True)
stroj = StrojSerializer(read_only=True)
slike = RadniNalogSlikaSerializer(many=True, read_only=True)
# VOZILO dohvaćamo preko putnog naloga
vozilo = serializers.SerializerMethodField()
status_display = serializers.CharField(source='get_status_display', read_only=True)
izvrsitelj_ime = serializers.CharField(source='izvrsitelj.get_full_name', read_only=True)
class Meta:
model = RadniNalog
fields = [
'id', 'broj_naloga', 'klijent', 'stroj', 'putni_nalog', 'vozilo', 'opis_kvara',
'izvrsitelj', 'izvrsitelj_ime', 'status', 'status_display',
'datum_kreiranja', 'datum_azuriranja', 'slika_kvara',
'potpis_klijenta', 'slike'
]
read_only_fields = ['datum_kreiranja', 'datum_azuriranja']
def get_vozilo(self, obj):
# Ako je radni nalog vezan za putni nalog, vrati podatke o vozilu
if obj.putni_nalog and obj.putni_nalog.vozilo:
return {
"id": obj.putni_nalog.vozilo.id,
"naziv": obj.putni_nalog.vozilo.naziv,
"registracija": obj.putni_nalog.vozilo.registracija,
"trenutni_kilometri": obj.putni_nalog.vozilo.trenutni_kilometri
}
return None
# --- 3. DEFAULT SERIALIZER ---
class RadniNalogSerializer(serializers.ModelSerializer):
# Dozvoljavamo unos, ali nije obavezan ako ga backend generira
broj_naloga = serializers.CharField(required=False, read_only=True)
class Meta:
model = RadniNalog
exclude = []
def validate_broj_naloga(self, value):
# Provjera jedinstvenosti prije nego što dođe do baze
if RadniNalog.objects.filter(broj_naloga=value).exists():
raise serializers.ValidationError("Radni nalog s ovim brojem već postoji.")
return value
def validate(self, data):
# Tvoja postojeća validacija klijenta i stroja
stroj = data.get('stroj')
klijent = data.get('klijent')
if stroj and klijent and stroj.vlasnik != klijent:
raise serializers.ValidationError(
{"stroj": "Odabrani stroj ne pripada odabranom klijentu."}
)
return data

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RadniNalogViewSet
# Kreiramo router i registriramo ViewSet za radne naloge
router = DefaultRouter()
router.register(r'radni-nalozi', RadniNalogViewSet, basename='radni-nalog')
urlpatterns = [
# Uključujemo sve rute koje router generira
path('', include(router.urls)),
]

View File

@@ -0,0 +1,106 @@
from rest_framework.decorators import action
from django.utils import timezone
from rest_framework import viewsets, filters, status # Dodan status
from django_filters.rest_framework import DjangoFilterBackend
from .models import RadniNalog, RadniNalogSlika, PutniNalog
from rest_framework.response import Response
from .serializers import (
RadniNalogSerializer,
RadniNalogListaSerializer,
RadniNalogDetaljiSerializer
)
from kalendar.services import kreiraj_kalendarski_unos
class RadniNalogViewSet(viewsets.ModelViewSet):
"""
API endpoint za upravljanje radnim nalozima.
Vozilo se sada filtrira i pretražuje preko Putnog Naloga.
"""
# 1. queryset: UKLONJEN 'vozilo' iz select_related
queryset = RadniNalog.objects.all().select_related(
'klijent', 'izvrsitelj', 'putni_nalog__vozilo'
)
serializer_class = RadniNalogSerializer
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
# 2. filterset_fields: 'vozilo' zamijenjeno putanjom preko putnog naloga
filterset_fields = ['status', 'izvrsitelj', 'klijent', 'putni_nalog__vozilo']
# 3. search_fields: pretraga ide preko putni_nalog__vozilo__registracija
search_fields = [
'broj_naloga',
'klijent__naziv',
'putni_nalog__vozilo__registracija',
'putni_nalog__vozilo__naziv'
]
ordering_fields = ['datum_kreiranja', 'status', 'broj_naloga']
ordering = ['-datum_kreiranja']
def get_serializer_class(self):
if self.action == 'list':
return RadniNalogListaSerializer
if self.action == 'retrieve':
return RadniNalogDetaljiSerializer
return RadniNalogSerializer
def get_queryset(self):
# Optimizacija upita ovisno o akciji
queryset = self.queryset
if self.action == 'retrieve':
queryset = queryset.prefetch_related('slike')
return queryset
def perform_create(self, serializer):
# Postavljamo izvršitelja ako nije poslan
if not serializer.validated_data.get('izvrsitelj'):
radni_nalog = serializer.save(izvrsitelj=self.request.user)
else:
radni_nalog = serializer.save()
# Kalendar servis
try:
kreiraj_kalendarski_unos(radni_nalog)
except Exception as e:
print(f"Greška pri kreiranju kalendarskog zapisa: {e}")
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
instance = serializer.instance
# Obrada višestrukih slika
files = request.FILES.getlist('slike')
for f in files:
RadniNalogSlika.objects.create(radni_nalog=instance, slika=f)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['get'], url_path='sljedeci-broj')
def dohvati_sljedeci_broj(self, request):
godina = timezone.now().year
prefix = f"{godina}-"
zadnji_nalog = RadniNalog.objects.filter(
broj_naloga__startswith=prefix
).order_by('-broj_naloga').first()
if zadnji_nalog:
try:
zadnji_broj = int(zadnji_nalog.broj_naloga.split('-')[-1])
novi_broj = zadnji_broj + 1
except (ValueError, IndexError):
novi_broj = 1
else:
novi_broj = 1
return Response({"broj_naloga": f"{prefix}{novi_broj:04d}"})