prije gemini provjere

This commit is contained in:
mariomitte
2026-05-31 19:21:25 +02:00
commit 9aff9c5dc1
191 changed files with 17016 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,161 @@
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 django.db import transaction
from operations.models import RadniNalog, RadniNalogSlika, PutniNalog, BrojacSekvence
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 sinkronizirano sa sekvencama'
def add_arguments(self, parser):
parser.add_argument('total_putovanja', type=int, nargs='?', default=3, help='Broj putnih naloga')
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 sinkronizirano sa sekvencama...')
success_count_rn = 0
trenutna_godina = timezone.now().year
# Definiramo ključ i prefikse za putne naloge
prefix_pn = f"PN-{trenutna_godina}-"
kljuc_sekvence_rn = f"radni_nalog_{trenutna_godina}"
for i in range(total_putovanja):
serviser = random.choice(serviseri)
vozilo = random.choice(vozila)
# 1. GENERIRANJE NEUNIŠTIVOG BROJA PUTNOG NALOGA
# Da skripta ne bi udarila u unique=True, tražimo stvarni zadnji PN u bazi
with transaction.atomic():
zadnji_pn = PutniNalog.objects.select_for_update().filter(
broj_naloga__startswith=prefix_pn
).order_by('-broj_naloga').first()
if zadnji_pn:
try:
zadnji_broj_pn = int(zadnji_pn.broj_naloga.split('-')[-1])
novi_broj_pn = zadnji_broj_pn + 1
except (ValueError, IndexError):
novi_broj_pn = PutniNalog.objects.count() + 1
else:
novi_broj_pn = 1
broj_pn = f"{prefix_pn}{novi_broj_pn:04d}"
# Kreiraj PUTNI NALOG
putni_nalog = PutniNalog.objects.create(
broj_naloga=broj_pn,
vozilo=vozilo,
korisnik=serviser,
relacija="Zagreb - Teren - Zagreb",
mjesto_odredista="Teren",
vrijeme_polaska=timezone.now()
)
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()]
if not klijenti_sa_strojima:
continue
klijent = random.choice(klijenti_sa_strojima)
stroj = random.choice(list(klijent.strojevi.all()))
try:
# Inicijaliziramo objekt bez broja_naloga kako bi se aktivirala
# naša neuništiva sekvencijska logika unutar save() metode modela!
nalog = RadniNalog(
klijent=klijent,
stroj=stroj,
putni_nalog=putni_nalog,
opis_kvara=random.choice(opisi_kvarova),
izvrsitelj=serviser,
status=RadniNalog.StatusRadaChoices.PLANIRANO
)
# Prvo moramo izvršiti osnovni save da model kroz BrojacSekvence dodijeli čisti broj
nalog.save()
# Ako slika postoji, dodajemo je NAKON što je broj kreiran (da putanja_slike_naloga ima ispravan folder)
if test_img_path:
with open(test_img_path, 'rb') as f:
nalog.slika_kvara.save(f'rn_{nalog.broj_naloga}.jpg', File(f), save=True)
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_{nalog.broj_naloga}_{k}.jpg'),
opis=f"Detalj kvara {k+1}"
)
# 5. Upis u kalendar
try:
kreiraj_kalendarski_unos(nalog)
except Exception as cal_err:
self.stdout.write(self.style.WARNING(f" ⚠️ Kalendar unos preskočen: {cal_err}"))
success_count_rn += 1
self.stdout.write(f"{nalog.broj_naloga} uspješno povezan u {broj_pn}")
except Exception as e:
self.stderr.write(f" ❌ Greška kod kreiranja RN: {e}")
# 3. NASUMIČNO ZATVARANJE (Simulacija završenog posla)
if random.random() > 0.5 and kreirani_radni_nalozi:
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,88 @@
# Generated by Django 6.0.5 on 2026-05-22 03:53
import django.db.models.deletion
import django.utils.timezone
import operations.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fleet', '__first__'),
('kupci', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BrojacSekvence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naziv_sekvence', models.CharField(max_length=50, unique=True)),
('zadnji_broj', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'Brojač sekvence',
'verbose_name_plural': 'Brojači sekvenci',
},
),
migrations.CreateModel(
name='PutniNalog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('broj_naloga', models.CharField(max_length=50, unique=True)),
('datum_izdavanja', models.DateField(default=django.utils.timezone.now)),
('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(blank=True, null=True)),
('vrijeme_polaska', models.DateTimeField(default=django.utils.timezone.now)),
('vrijeme_povratka', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(choices=[('OTVOREN', 'Otvoren'), ('ZAVRSEN', 'Završen')], default='OTVOREN', max_length=15)),
('korisnik', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('vozilo', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='putni_nalozi', to='fleet.vozilo', verbose_name='Službeno vozilo')),
],
options={
'verbose_name': 'Putni nalog',
'verbose_name_plural': 'Putni nalozi',
},
),
migrations.CreateModel(
name='RadniNalog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('broj_naloga', models.CharField(blank=True, max_length=20, unique=True)),
('opis_kvara', models.TextField()),
('status', models.CharField(choices=[('PLANIRANO', 'Planirano'), ('U_RADU', 'U radu'), ('ZAVRSENO', 'Završen')], default='PLANIRANO', max_length=20)),
('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(blank=True, null=True, upload_to=operations.models.putanja_slike_naloga)),
('potpis_klijenta', models.ImageField(blank=True, null=True, upload_to='potpisi/%Y/%m/')),
('izvrsitelj', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dodijeljeni_nalozi', to=settings.AUTH_USER_MODEL)),
('klijent', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='radni_nalozi', to='kupci.kupac')),
('putni_nalog', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radni_nalozi', to='operations.putninalog')),
('stroj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='radni_nalozi', to='fleet.stroj', verbose_name='Stroj na popravku')),
],
options={
'verbose_name': 'Radni nalog',
'verbose_name_plural': 'Radni nalozi',
},
),
migrations.CreateModel(
name='RadniNalogSlika',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slika', models.ImageField(upload_to=operations.models.putanja_galerije_naloga, verbose_name='Slika')),
('opis', models.CharField(blank=True, max_length=100, verbose_name='Kratki opis slike')),
('radni_nalog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slike', to='operations.radninalog')),
],
options={
'verbose_name': 'Dodatna slika naloga',
'verbose_name_plural': 'Dodatne slike naloga',
},
),
]

View File

@@ -0,0 +1,98 @@
# operations/mixins.py
from django.db import transaction
from rest_framework import serializers
from rest_framework import status
class DynamicFieldsModelSerializerMixin:
"""
Slojeviti Mixin za SERIALIZERE.
Čisti prazne stringove iz FormData i dinamički uvozi ugniježđene relacije (Lazy Load).
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get('request')
# Ako smo na GET zahtjevu, automatski aktiviramo ugniježđeni prikaz (Read-only)
if request and request.method == 'GET':
self._setup_nested_serializers()
def _setup_nested_serializers(self):
nested_config = getattr(self.Meta, 'nested_fields_config', {})
for field_name, serializer_path in nested_config.items():
if field_name in self.fields:
try:
module_path, class_name = serializer_path.rsplit('.', 1)
module = __import__(module_path, fromlist=[class_name])
serializer_class = getattr(module, class_name)
self.fields[field_name] = serializer_class(read_only=True, context=self.context)
except (ImportError, AttributeError) as e:
print(f"[Mixin Error] Neuspješan dinamički uvoz za {serializer_path}: {e}")
def to_internal_value(self, data):
if hasattr(data, 'dict'): # Pretvara prazne HTML stringove "" u prave Python None vrijednosti
data = data.copy()
for key, value in data.items():
if value == '':
data[key] = None
return super().to_internal_value(data)
class AstroBridgeViewSetMixin:
"""
Slojeviti Mixin za VIEWSETOVE.
Presreće perform_create/update za automatsko spremanje request.FILES u galerije
i omata izlazni JSON u standardni omotač (Envelope) za Astro proxy.
"""
auto_handle_files = False
file_field_name = 'slike'
related_file_model = None
related_file_fk = None
def finalize_response(self, request, response, *args, **kwargs):
"""Omata DRF odgovor u stabilnu strukturu za Astro klijent."""
if isinstance(response.data, (dict, list)):
if status.is_success(response.status_code):
response.data = {
"success": True,
"data": response.data,
"errors": None
}
else:
response.data = {
"success": False,
"data": None,
"errors": response.data
}
return super().finalize_response(request, response, *args, **kwargs)
def perform_create(self, serializer):
instance = serializer.save()
if self.auto_handle_files:
self._process_attached_files(instance)
return instance
def perform_update(self, serializer):
instance = serializer.save()
if self.auto_handle_files:
self._process_attached_files(instance)
return instance
def _process_attached_files(self, instance):
"""Automatski izvlači višestruki upload iz FormData boundary-ja."""
request = self.request
if not self.related_file_model or not self.related_file_fk:
return
files = request.FILES.getlist(self.file_field_name)
opis = request.data.get('slika_opis', f"Prilog uz {str(instance)}")
for f in files:
build_kwargs = {
self.related_file_fk: instance,
'slika': f,
}
if hasattr(self.related_file_model, 'opis'):
build_kwargs['opis'] = opis
self.related_file_model.objects.create(**build_kwargs)

View File

@@ -0,0 +1,248 @@
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
class BrojacSekvence(models.Model):
"""
Tablica koja pamti isključivo zadnji iskorišteni broj za bilo koji entitet.
Broj se ovdje samo povećava i nikada se ne briše.
"""
naziv_sekvence = models.CharField(max_length=50, unique=True) # npr. 'radni_nalog_2026'
zadnji_broj = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = "Brojač sekvence"
verbose_name_plural = "Brojači sekvenci"
def __str__(self):
return f"{self.naziv_sekvence}: {self.zadnji_broj}"
# --- FUNKCIJE ZA DINAMIČKE PUTANJE ---
def putanja_slike_naloga(instance, filename):
# Čistimo broj naloga od duplih oznaka za putanju direktorija
broj = instance.broj_naloga.replace('RN-', '') if instance.broj_naloga else 'nepoznato'
return f'radni-nalozi/RN-{broj}/{filename}'
def putanja_galerije_naloga(instance, filename):
broj = instance.radni_nalog.broj_naloga.replace('RN-', '') if instance.radni_nalog.broj_naloga else 'nepoznato'
return f'radni-nalozi/RN-{broj}/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šen'
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):
if self.broj_naloga == "":
self.broj_naloga = None
# 1. AUTOMATSKO GENERIRANJE BROJA NALOGA KROZ SEKVENCIJSKU TABLICU
if not self.broj_naloga:
godina = timezone.now().year
kljuc_sekvence = f"radni_nalog_{godina}"
prefix = f"RN-{godina}-"
# Atomska transakcija zaključava samo brojač, sprječavajući dupliranje
with transaction.atomic():
# Dohvati ili kreiraj brojač za tekuću godinu i zaključaj redak za ažuriranje
brojac, created = BrojacSekvence.objects.select_for_update().get_or_create(
naziv_sekvence=kljuc_sekvence
)
# Ako je brojač tek kreiran, a u bazi već imaš povijest (npr. stigao si do 25),
# radimo automatsku sinkronizaciju da ne krene od 1 ako tablica nije prazna
if created:
zadnji_u_bazi = RadniNalog.objects.filter(broj_naloga__startswith=prefix).order_by('-broj_naloga').first()
if zadnji_u_bazi:
try:
brojac.zadnji_broj = int(zadnji_u_bazi.broj_naloga.split('-')[-1])
except (ValueError, IndexError):
brojac.zadnji_broj = 0
# Uvećaj sekvencu za 1 (Čak i ako se nalozi obrišu, ovaj broj samo raste!)
brojac.zadnji_broj += 1
brojac.save()
# Dodijeli savršeno formatiran broj
self.broj_naloga = f"{prefix}{brojac.zadnji_broj:04d}"
else:
if self.broj_naloga.startswith("RN-RN-"):
self.broj_naloga = self.broj_naloga.replace("RN-RN-", "RN-")
# 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
ostali_otvoreni = pn.radni_nalozi.exclude(
id=self.id,
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):
# POPRAVLJENO: Budući da polje broj_naloga već u bazi sadrži "RN-2026-0004",
# ovdje izbacujemo fiksni prefiks "RN-" kako se ne bi duplirao pri pretvaranju objekta u string!
return f"{self.broj_naloga} | {self.stroj.naziv}"
class RadniNalogSlika(models.Model):
radni_nalog = models.ForeignKey(
RadniNalog,
related_name='slike',
on_delete=models.CASCADE
)
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,110 @@
# operations/serializers.py
from rest_framework import serializers
from .models import RadniNalog, RadniNalogSlika, PutniNalog
from .mixins import DynamicFieldsModelSerializerMixin
from .services import generiraj_i_kreiraj_putni_nalog # Uvozimo servisni sloj
class RadniNalogSlikaSerializer(serializers.ModelSerializer):
class Meta:
model = RadniNalogSlika
fields = ['id', 'slika', 'opis']
read_only_fields = ['id', 'datum_dodavanja']
class PutniNalogMinimalSerializer(serializers.ModelSerializer):
class Meta:
model = PutniNalog
fields = ['id', 'broj_naloga', 'status', 'pocetna_km']
class RadniNalogDetaljiSerializer(DynamicFieldsModelSerializerMixin, serializers.ModelSerializer):
slike = RadniNalogSlikaSerializer(many=True, read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
izvrsitelj_ime = serializers.CharField(source='izvrsitelj.get_full_name', read_only=True)
vozilo = serializers.SerializerMethodField()
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']
nested_fields_config = {
'klijent': 'kupci.serializers.KupacListaSerializer',
'stroj': 'fleet.serializers.StrojSerializer',
'putni_nalog': 'operations.serializers.PutniNalogMinimalSerializer'
}
def get_vozilo(self, obj):
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,
"status_prikaz": obj.putni_nalog.vozilo.status
}
return None
class RadniNalogSerializer(DynamicFieldsModelSerializerMixin, serializers.ModelSerializer):
broj_naloga = serializers.CharField(required=False, read_only=True)
class Meta:
model = RadniNalog
fields = '__all__'
def validate(self, data):
stroj = data.get('stroj') or (self.instance.stroj if self.instance else None)
klijent = data.get('klijent') or (self.instance.klijent if self.instance else None)
if stroj and klijent and stroj.vlasnik != klijent:
raise serializers.ValidationError({"stroj": "Odabrani stroj ne pripada odabranom klijentu."})
return data
class KreirajPutniNalogSerializer(DynamicFieldsModelSerializerMixin, serializers.ModelSerializer):
radni_nalog_id = serializers.IntegerField(write_only=True)
class Meta:
model = PutniNalog
fields = ['id', 'radni_nalog_id', 'vozilo', 'broj_naloga']
read_only_fields = ['id', 'broj_naloga']
def validate_radni_nalog_id(self, value):
try:
nalog = RadniNalog.objects.get(id=value)
except RadniNalog.DoesNotExist:
raise serializers.ValidationError("Radni nalog s ovim ID-jem ne postoji.")
if nalog.putni_nalog is not None:
raise serializers.ValidationError("Za ovaj radni nalog već je izrađen putni nalog.")
if not nalog.izvrsitelj:
raise serializers.ValidationError("Radni nalog mora imati dodijeljenog izvršitelja.")
return value
def create(self, validated_data):
# Sva teška logika je uklonjena odavde i poziva se iz operacije.py
return generiraj_i_kreiraj_putni_nalog(
radni_nalog_id=validated_data['radni_nalog_id'],
vozilo=validated_data.get('vozilo')
)
class PutniNalogSerializer(serializers.ModelSerializer):
vozilo_detalji = serializers.SerializerMethodField()
korisnik_ime = serializers.CharField(source='korisnik.get_full_name', read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = PutniNalog
fields = [
'id', 'broj_naloga', 'vozilo', 'vozilo_detalji', 'korisnik', 'korisnik_ime',
'relacija', 'mjesto_odredista', 'pocetna_km', 'zavrsna_km', 'status',
'status_display', 'vrijeme_polaska', 'vrijeme_povratka', 'datum_izdavanja'
]
read_only_fields = ['id', 'broj_naloga', 'vrijeme_polaska', 'datum_izdavanja']
def get_vozilo_detalji(self, obj):
from fleet.serializers import VoziloListaSerializer
if obj.vozilo:
return VoziloListaSerializer(obj.vozilo, context=self.context).data
return None

View File

@@ -0,0 +1,122 @@
# operations/services.py
import os
from PIL import Image
from io import BytesIO
import requests
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
from .models import RadniNalog, PutniNalog
from kalendar.services import kreiraj_kalendarski_unos
def izvrsi_perform_create_logiku(serializer, user):
"""
Konzistentna poslovna logika za kreiranje radnog naloga.
Dodjeljuje izvršitelja i automatski okida kalendarski servis.
"""
if not serializer.validated_data.get('izvrsitelj'):
radni_nalog = serializer.save(izvrsitelj=user)
else:
radni_nalog = serializer.save()
try:
kreiraj_kalendarski_unos(radni_nalog)
except Exception as e:
# Možeš uvesti standardni logging sustav umjesto printa
print(f"Greška pri kreiranju kalendarskog zapisa: {e}")
return radni_nalog
def generiraj_i_kreiraj_putni_nalog(radni_nalog_id, vozilo):
"""
Enkapsulirana atomska logika za generiranje jedinstvenog broja sekvence
i kreiranje putnog naloga, sprječavajući dupliciranje brojeva pod opterećenjem.
"""
radni_nalog = RadniNalog.objects.get(id=radni_nalog_id)
godina = timezone.now().year
prefix = f"PN-{godina}-"
with transaction.atomic():
# Zaključavamo zapise za tekuću godinu (Pessimistic Locking)
zadnji_pn = PutniNalog.objects.select_for_update().filter(
broj_naloga__startswith=prefix
).order_by('-broj_naloga').first()
if zadnji_pn:
try:
zadnji_broj_str = zadnji_pn.broj_naloga.split('-')[-1]
novi_broj = int(zadnji_broj_str) + 1
except (ValueError, IndexError):
novi_broj = 1
else:
novi_broj = 1
# Formatiranje broja: PN-2026-0001
broj_pn = f"{prefix}{novi_broj:04d}"
# Siguran upis u bazu
putni_nalog = PutniNalog.objects.create(
broj_naloga=broj_pn,
vozilo=vozilo,
korisnik=radni_nalog.izvrsitelj,
relacija=f"Zagreb - {radni_nalog.klijent.grad} - Zagreb",
mjesto_odredista=radni_nalog.klijent.grad,
vrijeme_polaska=timezone.now()
)
# Povezivanje s radnim nalogom
radni_nalog.putni_nalog = putni_nalog
radni_nalog.save()
return putni_nalog
def get_optimized_image(image_url, width=600):
"""
On-the-fly optimizacija slike u memoriji.
Prima URL ili lokalnu stazu, vraća sirove bajtove WebP slike.
"""
# 1. Dohvaćanje slike (podržava lokalne relativne staze i apsolutne URL-ove)
if image_url.startswith('http://') or image_url.startswith('https://'):
odgovor = requests.get(image_url, timeout=5)
odgovor.raise_for_status()
img_data = BytesIO(odgovor.content)
else:
# Pretvaramo relativnu stazu (npr. /media/slika.jpg) u apsolutnu stazu na disku
# Mičemo početni slash ako postoji da osiguramo ispravno spajanje staza
cista_staza = image_url.lstrip('/')
if cista_staza.startswith('media/'):
cista_staza = cista_staza.replace('media/', '', 1)
staza_na_disku = os.path.join(settings.MEDIA_ROOT, cista_staza)
if not os.path.exists(staza_na_disku):
raise FileNotFoundError(f"Slika nije pronađena na lokaciji: {staza_na_disku}")
with open(staza_na_disku, 'rb') as f:
img_data = BytesIO(f.read())
# 2. Obrada slike kroz PIL (Pillow) paket
with Image.open(img_data) as img:
# Osiguranje kompatibilnosti profila boja (RGBA -> RGB za WebP)
if img.mode in ('RGBA', 'LA'):
pozadina = Image.new('RGB', img.size, (255, 255, 255))
pozadina.paste(img, mask=img.split()[3])
img = pozadina
elif img.mode == 'P':
img = img.convert('RGB')
# Proporcionalni resize ako slika prelazi željenu širinu
if img.width > width:
faktor_skaliranja = width / float(img.width)
nova_visina = int(float(img.height) * float(faktor_skaliranja))
img = img.resize((width, nova_visina), Image.Resampling.LANCZOS)
# 3. Spremanje u memorijski buffer umjesto na disk
memorijski_buffer = BytesIO()
# Smanjujemo kvalitetu na 80% (idealni omjer oštrine i težine u bajtovima)
img.save(memorijski_buffer, format='WEBP', quality=80)
memorijski_buffer.seek(0)
return memorijski_buffer.getvalue()

View File

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
# operations/views.py
from django.http import HttpResponse
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, filters, permissions, status
from rest_framework.views import APIView
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import RadniNalog, RadniNalogSlika, PutniNalog
from .mixins import AstroBridgeViewSetMixin
from .services import izvrsi_perform_create_logiku, get_optimized_image
from .serializers import (
RadniNalogSerializer,
RadniNalogDetaljiSerializer,
KreirajPutniNalogSerializer,
PutniNalogSerializer
)
class RadniNalogViewSet(AstroBridgeViewSetMixin, viewsets.ModelViewSet):
queryset = RadniNalog.objects.all().select_related(
'klijent', 'izvrsitelj', 'putni_nalog__vozilo'
)
auto_handle_files = True
file_field_name = 'slike'
related_file_model = RadniNalogSlika
related_file_fk = 'radni_nalog'
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
filterset_fields = ['status', 'izvrsitelj', 'klijent', 'putni_nalog__vozilo']
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 in ['create', 'update', 'partial_update']:
return RadniNalogSerializer
return RadniNalogDetaljiSerializer
def get_queryset(self):
queryset = self.queryset
if self.action in ['list', 'retrieve']:
queryset = queryset.prefetch_related('slike')
return queryset
def perform_create(self, serializer):
# Delegiramo izvršavanje u operacije.py
return izvrsi_perform_create_logiku(serializer, self.request.user)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
instance.refresh_from_db()
izlazni_serializer = RadniNalogDetaljiSerializer(instance, context={'request': request})
return Response(izlazni_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', True)
instance = self.get_object()
serializer = RadniNalogSerializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
instance.refresh_from_db()
return Response(RadniNalogDetaljiSerializer(instance, context={'request': request}).data)
@action(detail=False, methods=['get'], url_path='sljedeci-broj', permission_classes=[permissions.AllowAny])
def dohvati_sljedeci_broj(self, request):
godina = timezone.now().year
kljuc_sekvence = f"radni_nalog_{godina}"
prefix = f"RN-{godina}-"
from .models import BrojacSekvence
brojac = BrojacSekvence.objects.filter(naziv_sekvence=kljuc_sekvence).first()
if brojac:
sljedeci_broj = brojac.zadnji_broj + 1
else:
zadnji_nalog = RadniNalog.objects.filter(broj_naloga__startswith=prefix).order_by('-broj_naloga').first()
if zadnji_nalog:
try:
sljedeci_broj = int(zadnji_nalog.broj_naloga.split('-')[-1]) + 1
except (ValueError, IndexError):
sljedeci_broj = 1
else:
sljedeci_broj = 1
sljedeci_broj_formatiran = f"{prefix}{sljedeci_broj:04d}"
return Response({"broj_naloga": sljedeci_broj_formatiran})
class PutniNalogViewSet(AstroBridgeViewSetMixin, viewsets.ModelViewSet):
queryset = PutniNalog.objects.all().order_by('-datum_izdavanja', '-id')
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'create':
return KreirajPutniNalogSerializer
return PutniNalogSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
putni_nalog = serializer.save()
return Response(
{
"id": putni_nalog.id,
"broj_naloga": putni_nalog.broj_naloga,
"status": putni_nalog.status,
"pocetna_km": putni_nalog.pocetna_km,
"message": "Putni nalog uspješno kreiran!"
},
status=status.HTTP_201_CREATED
)
class ProxyImageView(APIView):
http_method_names = ['get']
permission_classes = [permissions.AllowAny]
def get(self, request):
image_url = request.query_params.get('url')
try:
width = int(request.query_params.get('w', 600)) # Defaultna širina za terensku karticu
except (ValueError, TypeError):
width = 600
if not image_url:
return Response({"error": "URL slike nije zadan"}, status=status.HTTP_400_BAD_REQUEST)
try:
# get_optimized_image vraća sirove binarne bajtove WebP slike iz BytesIO-a
image_data = get_optimized_image(image_url, width=width)
response = HttpResponse(image_data, content_type="image/webp")
# 🚀 KLJUČNO ZA OFFLINE I BRZINU: Kažemo pregledniku da kešira sliku na 7 dana
response['Cache-Control'] = 'public, max-age=604800, immutable'
return response
except Exception as e:
print(f"[ProxyImageView Krah] Detalji: {e}")
return Response({"error": f"Greška pri optimizaciji: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)