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

206
.gitignore vendored Normal file
View File

@@ -0,0 +1,206 @@
# ---> Python
# Django
staticfiles/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
#lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

10
001.BACKEND/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
venv/
.venv/
__pycache__/
*.pyc
.git/
.gitignore
db.sqlite3
.env
staticfiles/
media/

4
001.BACKEND/.env.example Normal file
View File

@@ -0,0 +1,4 @@
DJANGO_SECRET_KEY=16!3r_muwp9(v@05v__em_-zuvsz=@3%mo$-i8&hd!c8bbop2$
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,v003-backend.captain.mitteworkspace.cloud
DJANGO_CORS_ALLOWED_ORIGINS=https://operativa.captain.mitteworkspace.cloud,https://operativa.local.mitteworkspace.cloud,http://localhost:4321,http://127.0.0.1:4321

6
001.BACKEND/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/env/Scripts/python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}"
]
}

50
001.BACKEND/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
FROM python:3.12-slim AS builder
# Sprječava Python da zapisuje .pyc datoteke na disk
ENV PYTHONDONTWRITEBYTECODE=1
# Sprječava Python da sprema ispis u međumemoriju (odmah vidiš logove)
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Instalacija sistemskih ovisnosti potrebnih za kompajliranje pojedinih Python paketa
# (npr. gcc i libpq-dev ako koristiš PostgreSQL)
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Nadogradnja pip-a i kopiranje requirements datoteke
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
# Instalacija paketa u lokalni direktorij unutar builder faze
RUN pip install --no-cache-dir --user -r requirements.txt
# --- 2. FAZA: Pokretanje (Prodajni Runtime) ---
FROM python:3.12-slim AS runner
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PATH=/root/.local/bin:$PATH
WORKDIR /app
# Ako koristiš PostgreSQL na produkciji, ovdje ti treba samo lagana runtime knjižnica
RUN apt-get update && apt-get install --no-install-recommends -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Kopiramo instalirane Python pakete iz prve faze
COPY --from=builder /root/.local /root/.local
# Kopiramo ostatak izvornog koda Django aplikacije
COPY . .
# Otvaramo port 8000 na kojem će slušati Gunicorn
EXPOSE 8000
# Pokretanje aplikacije produkcijskim Gunicorn serverom
# Zamijeni "core" s točnim nazivom mape u kojoj ti se nalazi wsgi.py datoteka!
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "core.wsgi:application"]

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

16
001.BACKEND/core/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()

View File

@@ -0,0 +1,14 @@
# core/celery.py
import os
from celery import Celery # 🚀 Ovdje uvoziš čistu Celery klasu
# Postavljamo zadani Django settings modul za 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
# Sve postavke vezane za Celery imaju 'CELERY_' prefiks u settings.py
app.config_from_object('django.conf:settings', namespace='CELERY')
# Automatski učitaj tasks.py iz svih registriranih Django aplikacija
app.autodiscover_tasks()

View File

@@ -0,0 +1,182 @@
"""
Django settings for core project.
"""
import os
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
SECRET_KEY = os.getenv(
'DJANGO_SECRET_KEY',
'django-insecure-!228(8gy#3a7l-@_^g1s4bipj&@*+_415+ulx0^-9jw(%ksdvy',
)
DEBUG = os.getenv('DJANGO_DEBUG', 'True').lower() in ('1', 'true', 'yes', 'on')
# Custom user model
AUTH_USER_MODEL = 'users.CustomUser'
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost').split(',')
if host.strip()
]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# plugins
'fleet',
'corsheaders',
'django_filters',
'rest_framework_simplejwt',
'rest_framework',
# Aplikacije
'users',
'kupci',
'operations',
'kalendar',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi!
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'core.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator' },
{ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator' },
{ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator' },
{ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator' },
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Europe/Zagreb'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
if DEBUG:
DEFAULT_PERMISSION_CLASSES = [ 'rest_framework.permissions.AllowAny' ]
else:
DEFAULT_PERMISSION_CLASSES = [ 'rest_framework.permissions.IsAuthenticated' ]
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend'
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': DEFAULT_PERMISSION_CLASSES
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'AUTH_HEADER_TYPES': ('Bearer',),
}
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 2. CSRF_TRUSTED_ORIGINS
# Primjer: https://app.tvojadomena.hr,https://api.tvojadomena.hr
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://127.0.0.1:4321,http://localhost:4321').split(',')
if origin.strip()
]
# CORS POSTAVKE - POPRAVLJENI ZAREZI I FORMALNI ORIGINI
if DEBUG:
CORS_ALLOW_ALL_ORIGINS = True
else:
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = [
host.strip()
for host in os.getenv('DJANGO_CORS_ALLOWED_ORIGINS', '').split(',')
if host.strip()
]
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'access-control-request-private-network',
]
# Ako preglednik pošalje PNA preflight zahtjev sa lokalne mreže,
# corsheaders će ga s ovim u potpunosti odobriti bez redirecta
CORS_ALLOW_PRIVATE_NETWORK = True
CORS_PREFLIGHT_MAX_AGE = 86400
# Celery Postavke
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Europe/Zagreb'

44
001.BACKEND/core/urls.py Normal file
View File

@@ -0,0 +1,44 @@
"""
URL configuration for core project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
# Admin panel
path('admin/', admin.site.urls),
# API endpointi
path('api/kupci/', include('kupci.urls')),
path('api/fleet/', include('fleet.urls')),
path('api/operativa/', include('operations.urls')),
path('api/kalendar/', include('kalendar.urls')),
path('api/users/', include('users.urls')),
# Endpoint za dobivanje tokena (Login)
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
# Endpoint za osvježavanje tokena (da se ne mora stalno logirati)
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
# Dodajemo pristup media datotekama samo u development fazi
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

16
001.BACKEND/core/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

View File

View File

@@ -0,0 +1,90 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.safestring import mark_safe
from .models import Stroj, Vozilo
from operations.models import RadniNalog, PutniNalog # Importamo modele iz druge aplikacije
# --- INLINE KLASE ---
class PutniNalogInline(admin.TabularInline):
"""Prikazuje putne naloge unutar pregleda vozila."""
model = PutniNalog
extra = 0
fields = ('broj_naloga', 'datum_izdavanja', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status')
readonly_fields = ('broj_naloga', 'datum_izdavanja', 'korisnik', 'pocetna_km', 'zavrsna_km', 'status')
can_delete = False
verbose_name = "Povijest putovanja"
verbose_name_plural = "Povijest putovanja za ovo vozilo"
class RadniNalogInline(admin.TabularInline):
model = RadniNalog
extra = 0
# Umjesto običnog polja 'broj_naloga', koristimo metodu 'link_na_nalog'
fields = ('link_na_nalog', 'datum_kreiranja', 'klijent', 'izvrsitelj', 'status')
readonly_fields = ('link_na_nalog', 'datum_kreiranja', 'klijent', 'izvrsitelj', 'status')
can_delete = False
def link_na_nalog(self, obj):
if obj.id:
# Generiramo URL za admin promjenu Radnog Naloga
url = reverse('admin:operations_radninalog_change', args=[obj.id])
return mark_safe(f'<a href="{url}" style="font-weight:bold;">RN-{obj.broj_naloga}</a>')
return "-"
link_na_nalog.short_description = 'Broj naloga'
# --- GLAVNE ADMIN KLASE ---
@admin.register(Vozilo)
class VoziloAdmin(admin.ModelAdmin):
list_display = ('naziv', 'registracija', 'status', 'pocetni_kilometri', 'trenutni_kilometri')
search_fields = ('naziv', 'registracija')
list_filter = ('status',)
ordering = ('naziv',)
# Dodajemo inline za putne naloge
inlines = [PutniNalogInline]
@admin.register(Stroj)
class StrojAdmin(admin.ModelAdmin):
list_display = (
'serijski_broj',
'prikaz_naziva',
'vlasnik',
'tip',
'radni_sati',
'registracija',
'datum_zadnjeg_atesta'
)
search_fields = (
'serijski_broj',
'naziv',
'marka',
'model_stroja',
'vlasnik__naziv',
'vlasnik__oib'
)
list_filter = ('tip', 'marka', 'vlasnik')
autocomplete_fields = ['vlasnik']
# Dodajemo inline za radne naloge
inlines = [RadniNalogInline]
fieldsets = (
('Osnovne informacije', {
'fields': ('vlasnik', 'naziv', 'tip', 'serijski_broj')
}),
('Detalji o modelu', {
'fields': (('marka', 'model_stroja'), 'godina_proizvodnje')
}),
('Tehnički podaci i održavanje', {
'fields': ('radni_sati', 'registracija', 'datum_zadnjeg_atesta'),
'classes': ('collapse',)
}),
)
def prikaz_naziva(self, obj):
return f"{obj.marka} {obj.model_stroja}".strip() or obj.naziv
prikaz_naziva.short_description = 'Marka i Model'

View File

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

View File

@@ -0,0 +1,103 @@
import random
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from faker import Faker
from fleet.models import Stroj
from kupci.models import Kupac
class Command(BaseCommand):
help = 'Popunjava bazu podataka isključivo Liebherr strojevima sinkronizirano s logistikom flote'
def add_arguments(self, parser):
parser.add_argument(
'total',
type=int,
help='Broj Liebherr strojeva koje želiš kreirati',
default=10
)
def handle(self, *args, **kwargs):
total = kwargs['total']
fake = Faker(['hr_HR'])
kupci = list(Kupac.objects.all())
if not kupci:
self.stdout.write(self.style.ERROR("Greška: Nema kupaca u bazi. Prvo pokreni populate_kupci!"))
return
# Bogatiji katalog usklađen s tvorničkim Liebherr oznakama i tvojim TIP_STROJA choices
liebherr_katalog = {
'dizalica_toranj': [
'132 EC-H 8 Litronic',
'280 EC-H 12',
'81 K.1',
'172 EC-B 8',
'125 K'
],
'dizalica_auto': [
'LTM 1030-2.1',
'LTM 1050-3.1',
'LTM 1060-3.1',
'LTM 1120-4.1',
'LTM 1350-6.1', # Dodan LTM 1350 (sukladno planiranim modifikacijama ventila)
'LTC 1050-3.1'
]
}
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Generiram {total} Liebherr strojeva u bazu...'))
created_count = 0
for _ in range(total):
tip = random.choice(['dizalica_toranj', 'dizalica_auto'])
model_naziv = random.choice(liebherr_katalog[tip])
# Odabiremo nasumičnog vlasnika za ovaj stroj
vlasnik_stroja = random.choice(kupci)
# Realistični Liebherr tvornički serijski brojevi (npr. LE-054321)
serijski = f"LE-{fake.numerify(text='0######')}"
# USKLAĐENO: Registracija za autodizalice sada ima ispravne razmake i crticu
# Koristimo reg. oznaku koja odgovara sjedištu vlasnika ili nasumični HR grad
reg = None
if tip == 'dizalica_auto':
gradovi = ['ZG', 'KA', 'RI', 'ST', 'OS', 'KR']
grad = random.choice(gradovi)
reg_broj = fake.numerify(text='####')
reg_slova = fake.bothify(text='??', letters='ABCDEFGHIJKLMNOPQRSTUVWXYZ')
reg = f"{grad} {reg_broj}-{reg_slova}"
# Radni sati dizalice
sati = random.uniform(800.0, 15000.0) if tip == 'dizalica_toranj' else random.uniform(200.0, 6000.0)
# Atesti unutar zadnjih godinu dana
datum_atesta = fake.date_between(start_date='-1y', end_date='today')
# USKLAĐENO: Generiramo lokaciju. Ako klijent/vlasnik ima definiran grad,
# stroj se primarno nalazi na gradilištu u tom gradu (ili okolici), inače koristimo fake.city()
lokacija_stroja = vlasnik_stroja.grad if (vlasnik_stroja and hasattr(vlasnik_stroja, 'grad')) else fake.city()
# Nasumična simulacija aktivnih gradilišta u regiji
if random.random() > 0.6:
lokacija_stroja = f"Gradilište {lokacija_stroja}"
try:
Stroj.objects.create(
vlasnik=vlasnik_stroja,
naziv=f"Liebherr {model_naziv}",
serijski_broj=serijski,
marka='Liebherr',
model_stroja=model_naziv,
godina_proizvodnje=random.randint(2012, 2025),
tip=tip,
radni_sati=round(sati, 1), # Zaokružujemo na 1 decimalu sukladno formama
registracija=reg,
datum_zadnjeg_atesta=datum_atesta
)
created_count += 1
self.stdout.write(f" ✅ Kreiran: Liebherr {model_naziv} (Vlasnik: {vlasnik_stroja.naziv})")
except Exception as e:
self.stderr.write(f" ❌ Greška kod S/N {serijski}: {e}")
continue
self.stdout.write(self.style.SUCCESS(f'\nGotovo! Uspješno dodano {created_count} Liebherr strojeva u bazu.'))

View File

@@ -0,0 +1,86 @@
import random
from django.core.management.base import BaseCommand
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from faker import Faker
from fleet.models import Vozilo
class Command(BaseCommand):
help = 'Popunjava bazu podataka s gospodarskim vozilima sinkronizirano s Astro logistikom'
def add_arguments(self, parser):
parser.add_argument(
'total',
type=int,
help='Broj vozila koje želiš kreirati',
default=5
)
def handle(self, *args, **kwargs):
total = kwargs['total']
fake = Faker(['hr_HR'])
# Modeli koji odgovaraju tvom voznom parku za mobilne servisne timove
modeli_podaci = [
'VW Transporter T6',
'Mercedes-Benz Vito',
'Citroen Jumper',
'Ford Transit Custom',
'Renault Kangoo Maxi',
'Peugeot Boxer',
'Opel Vivaro',
]
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Kreiram {total} servisnih vozila u flotu...'))
brojac = 0
maksimalni_pokusaji = total * 5
pokusaji = 0
while brojac < total and pokusaji < maksimalni_pokusaji:
pokusaji += 1
model_naziv = random.choice(modeli_podaci)
# Generiranje realistične registracije u formatu: ZG 1234-AB
gradovi = ['ZG', 'KR', 'KA', 'ST', 'RI', 'OS', 'VZ', 'PU']
grad = random.choice(gradovi)
reg_broj = fake.numerify(text=random.choice(['###', '####']))
reg_slova = fake.bothify(text=random.choice(['?', '??']), letters='ABCDEFGHIJKLMNOPQRSTUVWXYZ')
registracija = f"{grad} {reg_broj}-{reg_slova}"
# Kilometraža: Trenutni kilometri moraju pratiti povijest korištenja vozila
pocetni = random.randint(10000, 220000)
dodatni = random.randint(1500, 45000)
trenutni = pocetni + dodatni
# POPRAVLJENO: Koristimo mala slova sukladno tvojim Choice ograničenjima u modelu
status_izbor = random.choice(['aktivan', 'aktivan', 'servis', 'neaktivan'])
try:
# Provjeravamo postoji li već ova registracija u bazi unaprijed
if Vozilo.objects.filter(registracija=registracija).exists():
continue
vozilo = Vozilo.objects.create(
naziv=model_naziv,
registracija=registracija,
pocetni_kilometri=pocetni,
trenutni_kilometri=trenutni,
status=status_izbor
)
brojac += 1
self.stdout.write(f" ✅ Kreirano: {vozilo.naziv} [{vozilo.registracija}] | Status: {status_izbor} ({brojac}/{total})")
except IntegrityError:
# Ako baza ipak prijavi duplikat registracije unutar transakcije, samo idemo dalje
continue
except ValidationError as ve:
# Ako se pojavi neka druga validacijska greška, ispiši je da znamo o čemu se radi
self.stderr.write(f" ⚠️ Validacijska greška za {model_naziv}: {ve}")
continue
except Exception as e:
self.stderr.write(f" ❌ Kritična greška pri upisu vozila: {e}")
break
self.stdout.write(self.style.SUCCESS(f'\nGotovo! Uspješno dodano {brojac} servisnih vozila u bazu flote.'))

View File

@@ -0,0 +1,53 @@
# Generated by Django 6.0.5 on 2026-05-22 03:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('kupci', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Vozilo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naziv', models.CharField(max_length=50, verbose_name='Interni naziv')),
('registracija', models.CharField(db_index=True, max_length=15, unique=True)),
('pocetni_kilometri', models.PositiveIntegerField(default=0)),
('trenutni_kilometri', models.PositiveIntegerField(default=0)),
('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'Na Servisu'), ('neaktivan', 'Izvan Pogona')], default='aktivan', max_length=20)),
],
options={
'verbose_name': 'Vozilo',
'verbose_name_plural': 'Vozila',
'ordering': ['naziv'],
},
),
migrations.CreateModel(
name='Stroj',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naziv', models.CharField(help_text='Npr. Liebherr LTM 1030', max_length=100)),
('serijski_broj', models.CharField(max_length=50, unique=True)),
('marka', models.CharField(blank=True, max_length=50)),
('model_stroja', models.CharField(blank=True, max_length=50)),
('godina_proizvodnje', models.PositiveIntegerField(blank=True, null=True)),
('tip', models.CharField(choices=[('dizalica_toranj', 'Toranjska dizalica'), ('dizalica_auto', 'Autodizalica'), ('vilicar', 'Viličar'), ('platforma', 'Radna platforma')], max_length=30)),
('radni_sati', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('registracija', models.CharField(blank=True, help_text='Za autodizalice i vozila', max_length=20, null=True)),
('datum_zadnjeg_atesta', models.DateField(blank=True, null=True)),
('vlasnik', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='strojevi', to='kupci.kupac', verbose_name='Vlasnik/Kupac')),
],
options={
'verbose_name': 'Stroj',
'verbose_name_plural': 'Strojevi',
'ordering': ['marka', 'model_stroja'],
},
),
]

View File

View File

@@ -0,0 +1,88 @@
from django.db import models
from django.core.exceptions import ValidationError
class Vozilo(models.Model):
STATUS_CHOICES = [
('aktivan', 'Aktivan'),
('servis', 'Na Servisu'),
('neaktivan', 'Izvan Pogona'),
]
naziv = models.CharField(max_length=50, verbose_name="Interni naziv")
registracija = models.CharField(max_length=15, unique=True, db_index=True)
# Kilometri
pocetni_kilometri = models.PositiveIntegerField(default=0)
trenutni_kilometri = models.PositiveIntegerField(default=0)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='aktivan'
)
class Meta:
verbose_name = "Vozilo"
verbose_name_plural = "Vozila"
ordering = ['naziv']
def clean(self):
# Osnovna logika: trenutni kilometri ne smiju biti manji od početnih
if self.trenutni_kilometri < self.pocetni_kilometri:
raise ValidationError({
'trenutni_kilometri': "Trenutni kilometri ne mogu biti manji od početnih."
})
def save(self, *args, **kwargs):
# Logika za nove zapise
if not self.pk:
self.trenutni_kilometri = self.pocetni_kilometri
self.registracija = self.registracija.upper().replace(" ", "")
# Pozivamo clean() prije spremanja (Django to ne radi automatski u save())
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return f"{self.naziv} ({self.registracija})"
class Stroj(models.Model):
TIP_STROJA = [
('dizalica_toranj', 'Toranjska dizalica'),
('dizalica_auto', 'Autodizalica'),
('vilicar', 'Viličar'),
('platforma', 'Radna platforma'),
]
# Poveznica s kupcem: Jedan kupac može imati mnogo strojeva
# --- RJEŠENJE ZA LOOP: Koristimo string 'kupci.Kupac' ---
vlasnik = models.ForeignKey(
'kupci.Kupac',
on_delete=models.CASCADE,
related_name='strojevi',
verbose_name="Vlasnik/Kupac"
)
naziv = models.CharField(max_length=100, help_text="Npr. Liebherr LTM 1030")
serijski_broj = models.CharField(max_length=50, unique=True)
marka = models.CharField(max_length=50, blank=True)
model_stroja = models.CharField(max_length=50, blank=True)
godina_proizvodnje = models.PositiveIntegerField(null=True, blank=True)
tip = models.CharField(max_length=30, choices=TIP_STROJA)
# Podaci za održavanje
radni_sati = models.DecimalField(max_digits=12, decimal_places=2, default=0)
registracija = models.CharField(max_length=20, blank=True, null=True, help_text="Za autodizalice i vozila")
datum_zadnjeg_atesta = models.DateField(null=True, blank=True)
class Meta:
verbose_name = "Stroj"
verbose_name_plural = "Strojevi"
ordering = ['marka', 'model_stroja']
def __str__(self):
# Mala nadogradnja da ne vraća prazan string ako marka/model nedostaju
oznaka = f"{self.marka} {self.model_stroja}".strip() or self.naziv
return f"{oznaka} (S/N: {self.serijski_broj})"

View File

@@ -0,0 +1,115 @@
from rest_framework import serializers
from .models import Stroj, Vozilo
class StrojShortSerializer(serializers.ModelSerializer):
"""
Kraća verzija serijalizatora za brze liste ili dropdown izbornike.
"""
class Meta:
model = Stroj
fields = ['id', 'naziv', 'serijski_broj', 'registracija']
class StrojSerializer(serializers.ModelSerializer):
"""
Glavni serializer za strojeve/dizalice.
"""
# Prikazuje naziv vlasnika (kupca) umjesto samo ID-a
# (source='vlasnik.naziv' jer je u modelu ForeignKey nazvan 'vlasnik')
vlasnik_naziv = serializers.ReadOnlyField(source='vlasnik.naziv')
# get_tip_display() pretvara 'dizalica_auto' u 'Autodizalica' (čitljivije za UI)
tip_human_readable = serializers.CharField(source='get_tip_display', read_only=True)
radni_nalozi = serializers.SerializerMethodField()
class Meta:
model = Stroj
fields = [
'id',
'vlasnik',
'vlasnik_naziv',
'naziv',
'marka',
'model_stroja',
'serijski_broj',
'tip',
'tip_human_readable',
'godina_proizvodnje',
'radni_sati',
'registracija',
'datum_zadnjeg_atesta',
'radni_nalozi', # Novo polje
]
def get_radni_nalozi(self, obj):
# Lokalni import rješava kružni import error s operations aplikacijom
from operations.serializers import RadniNalogSerializer
# Dohvaćamo radne naloge i sortiramo ih tako da najnoviji budu prvi
queryset = obj.radni_nalozi.all().order_by('-datum_kreiranja')
# Vraćamo serijalizirane podatke
return RadniNalogSerializer(queryset, many=True, context=self.context).data
class VoziloSerializer(serializers.ModelSerializer):
"""
Osnovni serializer koji se koristi za Create/Update operacije.
"""
class Meta:
model = Vozilo
fields = '__all__'
class VoziloListaSerializer(serializers.ModelSerializer):
"""
Serializer za brzi prikaz u tablicama.
"""
# Ljudski čitljiv status (npr. 'Na Servisu' umjesto 'servis')
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = Vozilo
fields = ['id', 'naziv', 'registracija', 'trenutni_kilometri', 'status', 'status_prikaz']
class VoziloListaSerializer(serializers.ModelSerializer):
"""
Serializer za brzi prikaz u tablicama.
"""
# Ljudski čitljiv status (npr. 'Na Servisu' umjesto 'servis')
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = Vozilo
fields = ['id', 'naziv', 'registracija', 'trenutni_kilometri', 'status', 'status_prikaz']
class VoziloDetaljiSerializer(serializers.ModelSerializer):
"""
Serializer s dodatnom statistikom za detaljni prikaz vozila.
"""
ukupno_predeno = serializers.SerializerMethodField()
status_prikaz = serializers.CharField(source='get_status_display', read_only=True)
putni_nalozi = serializers.SerializerMethodField()
class Meta:
model = Vozilo
fields = [
'id',
'naziv',
'registracija',
'pocetni_kilometri',
'trenutni_kilometri',
'ukupno_predeno',
'status',
'status_prikaz',
'putni_nalozi',
]
def get_ukupno_predeno(self, obj):
"""Izračunava razliku između trenutnih i početnih kilometara"""
return obj.trenutni_kilometri - obj.pocetni_kilometri
def get_putni_nalozi(self, obj):
from operations.serializers import PutniNalogSerializer
queryset = obj.putni_nalozi.all()
return PutniNalogSerializer(queryset, many=True, context=self.context).data

View File

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

11
001.BACKEND/fleet/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import StrojViewSet, VoziloViewSet
router = DefaultRouter()
router.register(r'strojevi', StrojViewSet, basename='stroj')
router.register(r'vozila', VoziloViewSet, basename='vozilo')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,72 @@
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Stroj, Vozilo
from .serializers import (
VoziloSerializer,
VoziloListaSerializer,
VoziloDetaljiSerializer,
StrojSerializer
)
class StrojViewSet(viewsets.ModelViewSet):
"""
ViewSet za pregled, kreiranje i uređivanje strojeva i dizalica.
"""
# select_related radi SQL JOIN i povlači podatke o kupcu odmah
queryset = Stroj.objects.all().select_related('vlasnik').prefetch_related('radni_nalozi')
serializer_class = StrojSerializer
# Dodajemo mogućnosti filtriranja, pretrage i sortiranja
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
# Omogućuje filtriranje strojeva po vlasniku ili tipu
# npr. /api/strojevi/?vlasnik=1 ili /api/strojevi/?tip=dizalica_auto
filterset_fields = ['vlasnik', 'tip']
# Omogućuje pretragu po nazivu, marki ili serijskom broju
search_fields = ['naziv', 'marka', 'serijski_broj', 'registracija']
# Omogućuje sortiranje po radnim satima ili godini proizvodnje
ordering_fields = ['radni_sati', 'godina_proizvodnje', 'datum_zadnjeg_atesta']
ordering = ['-datum_zadnjeg_atesta'] # Defaultno prikazuje one koji su zadnji atestirani
class VoziloViewSet(viewsets.ModelViewSet):
"""
ViewSet za pregled, kreiranje i uređivanje voznog parka.
Usklađen sa StrojViewSet arhitekturom.
"""
queryset = Vozilo.objects.all()
# Postavljanje filter backenda (isto kao u StrojViewSet)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
# Omogućuje filtriranje vozila po statusu
# npr. /api/fleet/vozila/?status=u_radu
filterset_fields = ['status']
# Omogućuje pretragu po nazivu i registraciji
search_fields = ['naziv', 'registracija']
# Omogućuje sortiranje po kilometrima ili statusu
ordering_fields = ['trenutni_kilometri', 'status']
ordering = ['naziv'] # Defaultno sortiranje po abecedi naziva
def get_serializer_class(self):
"""
Dinamički odabir serializera za optimalne performanse.
"""
if self.action == 'list':
return VoziloListaSerializer
if self.action == 'retrieve':
return VoziloDetaljiSerializer
# Default za POST/PUT/PATCH
return VoziloSerializer

View File

View File

@@ -0,0 +1,81 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import Dogadaj
@admin.register(Dogadaj)
class DogadajAdmin(admin.ModelAdmin):
# 1. Postavke liste (Dashboard pregled)
list_display = ('prikaz_naslova', 'tip_ikona', 'pocetak', 'kraj', 'get_izvrsitelj', 'link_na_rn')
list_filter = ('tip', 'pocetak', 'serviser_u_kalendaru')
search_fields = ('naslov', 'opis', 'radni_nalog__broj_naloga', 'klijent__naziv')
ordering = ('-pocetak',)
# 2. Optimizacija baze (Spriječava N+1 problem zbog @property-ja)
def get_queryset(self, request):
return super().get_queryset(request).select_related(
'radni_nalog__izvrsitelj',
'serviser_u_kalendaru',
'klijent',
'vozilo'
)
# --- CUSTOM METODE ZA PRIKAZ ---
def prikaz_naslova(self, obj):
"""Bojanje naslova prema tipu za brzu vizualnu orijentaciju"""
boje = {
'servis': '#2980b9', # Plava
'isporuka': '#27ae60', # Zelena
'sastanak': '#f39c12', # Narančasta
'biljeska': '#7f8c8d', # Siva
}
boja = boje.get(obj.tip, 'black')
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', boja, obj.naslov)
prikaz_naslova.short_description = "Naslov"
def tip_ikona(self, obj):
"""Dodaje malu ikonu uz tip događaja"""
ikone = {
'servis': '🛠️',
'isporuka': '🏗️',
'sastanak': '🤝',
'biljeska': '📝',
}
return format_html('{} {}', ikone.get(obj.tip, '📅'), obj.get_tip_display())
tip_ikona.short_description = "Tip"
def get_izvrsitelj(self, obj):
"""Prikazuje izvršitelja koristeći logiku iz model @property"""
korisnik = obj.izvrsitelj
if korisnik:
return korisnik.get_full_name() or korisnik.username
return format_html('<i style="color: #999;">Nije dodijeljeno</i>')
get_izvrsitelj.short_description = "Izvršitelj (Serviser)"
def link_na_rn(self, obj):
"""Prikazuje broj radnog naloga ako postoji"""
if obj.radni_nalog:
return format_html('<b>#{}</b>', obj.radni_nalog.broj_naloga)
return "-"
link_na_rn.short_description = "Radni Nalog"
# 3. Organizacija polja u formi (Fieldsets)
fieldsets = (
('Glavne informacije', {
'fields': (('naslov', 'tip'), 'opis')
}),
('Vrijeme održavanja', {
'fields': (('pocetak', 'kraj'),)
}),
('Poveznice sustava', {
'fields': ('radni_nalog', 'klijent', 'vozilo'),
'description': "Povežite događaj s klijentom ili radnim nalogom za bolju evidenciju."
}),
('Odgovornost', {
'fields': ('serviser_u_kalendaru',),
'description': "NAPOMENA: Ako je povezan Radni Nalog, sustav ignorira ovo polje i koristi servisera s naloga."
}),
)
# Postavljanje radnog naloga kao readonly ako želiš spriječiti ručno mijenjanje veza
# readonly_fields = ('radni_nalog',)

View File

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

View File

@@ -0,0 +1,42 @@
# Generated by Django 6.0.5 on 2026-05-22 03:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fleet', '0001_initial'),
('kupci', '0001_initial'),
('operations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Dogadaj',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naslov', models.CharField(max_length=200)),
('opis', models.TextField(blank=True)),
('tip', models.CharField(choices=[('servis', 'Radni Nalog / Servis'), ('isporuka', 'Isporuka Stroja'), ('sastanak', 'Sastanak s kupcem'), ('biljeska', 'Interna bilješka')], default='biljeska', max_length=20)),
('pocetak', models.DateTimeField()),
('kraj', models.DateTimeField()),
('datum_kreiranja', models.DateTimeField(auto_now_add=True)),
('datum_azuriranja', models.DateTimeField(auto_now=True)),
('klijent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='kupci.kupac')),
('radni_nalog', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kalendar_termin', to='operations.radninalog')),
('serviser_u_kalendaru', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dodatni_zadaci', to=settings.AUTH_USER_MODEL)),
('vozilo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.vozilo')),
],
options={
'verbose_name': 'Kalendar',
'verbose_name_plural': 'Kalendar',
'ordering': ['pocetak'],
},
),
]

View File

@@ -0,0 +1,61 @@
from django.db import models
from django.conf import settings
class Dogadaj(models.Model):
TIP_DOGADAJA = [
('servis', 'Radni Nalog / Servis'),
('isporuka', 'Isporuka Stroja'),
('sastanak', 'Sastanak s kupcem'),
('biljeska', 'Interna bilješka'),
]
naslov = models.CharField(max_length=200)
opis = models.TextField(blank=True)
tip = models.CharField(max_length=20, choices=TIP_DOGADAJA, default='biljeska')
pocetak = models.DateTimeField()
kraj = models.DateTimeField()
# Automatsko praćenje izmjena samog događaja u kalendaru
datum_kreiranja = models.DateTimeField(auto_now_add=True)
datum_azuriranja = models.DateTimeField(auto_now=True)
# Veze
klijent = models.ForeignKey('kupci.Kupac', on_delete=models.SET_NULL, null=True, blank=True)
vozilo = models.ForeignKey('fleet.Vozilo', on_delete=models.SET_NULL, null=True, blank=True)
radni_nalog = models.OneToOneField(
'operations.RadniNalog',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kalendar_termin'
)
# Serviser koji je dodijeljen direktno u kalendaru (za sastanke/bilješke)
# Ako postoji radni_nalog, koristit ćemo izvrsitelja s naloga
serviser_u_kalendaru = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='dodatni_zadaci'
)
class Meta:
verbose_name = "Kalendar"
verbose_name_plural = "Kalendar"
ordering = ['pocetak']
@property
def izvrsitelj(self):
"""
Pametna metoda koja vraća servisera:
1. Ako je termin vezan za Radni Nalog, uzmi njegovog izvršitelja.
2. Ako nije, uzmi servisera upisanog u kalendar.
"""
if self.radni_nalog:
return self.radni_nalog.izvrsitelj
return self.serviser_u_kalendaru
def __str__(self):
return f"{self.naslov} - {self.izvrsitelj}"

View File

@@ -0,0 +1,77 @@
from rest_framework import serializers
from .models import Dogadaj
import re
class DogadajSerializer(serializers.ModelSerializer):
# 1. Mapiranje za standardne kalendarske knjižnice
title = serializers.CharField(source='naslov')
start = serializers.DateTimeField(source='pocetak')
end = serializers.DateTimeField(source='kraj')
# 2. Dinamički podaci (korištenje @property-ja i Choice polja)
izvrsitelj_ime = serializers.SerializerMethodField()
tip_display = serializers.CharField(source='get_tip_display', read_only=True)
# 3. Dodatne informacije za UI
boja = serializers.SerializerMethodField()
je_radni_nalog = serializers.SerializerMethodField()
# 4. NOVA RAZDVOJENA POLJA ZA VOCILO I ČISTI OPIS KWARTA
vozilo_naziv = serializers.SerializerMethodField()
opis_cisti = serializers.SerializerMethodField()
class Meta:
model = Dogadaj
fields = [
'id', 'title', 'start', 'end', 'tip', 'tip_display',
'opis', 'opis_cisti', 'vozilo_naziv', 'izvrsitelj_ime',
'boja', 'je_radni_nalog', 'klijent', 'vozilo', 'radni_nalog'
]
def get_izvrsitelj_ime(self, obj):
"""Dohvaća ime preko tvog @property izvrsitelj u modelu"""
korisnik = obj.izvrsitelj
if korisnik:
return korisnik.get_full_name() or korisnik.username
return "Nije dodijeljeno"
def get_boja(self, obj):
"""Dodjeljuje boju ovisno o tipu događaja"""
boje = {
'servis': '#3498db', # Plava
'isporuka': '#2ecc71', # Zelena
'sastanak': '#f1c40f', # Žuta
'biljeska': '#95a5a6', # Siva
}
return boje.get(obj.tip, '#34495e')
def get_je_radni_nalog(self, obj):
"""Pomaže frontendu da zna treba li prikazati link na radni nalog"""
return obj.radni_nalog is not None
def _parsiraj_spojeni_opis(self, obj):
"""Pomoćna metoda koja analizira tekst i vraća (vozilo, opis)"""
sirovi_tekst = obj.opis or ""
# Ako tekst sadrži ključne riječi, razdvajamo ga pomoću Regexa na backendu
if "Vozilo:" in sirovi_tekst or "Opis kvara:" in sirovi_tekst:
vozilo_match = re.search(r"Vozilo:\s*(.*?)(?=\s*Opis kvara:|$)", sirovi_tekst, re.IGNORECASE)
opis_match = re.search(r"Opis kvara:\s*(.*)", sirovi_tekst, re.IGNORECASE)
vozilo = vozilo_match.group(1).strip() if vozilo_match else None
opis = opis_match.group(1).strip() if opis_match else sirovi_tekst
return vozilo, opis
# Ako je polje vozilo na modelu već popunjeno kao relacija/tekst, iskoristi ga
model_vozilo = str(obj.vozilo) if obj.vozilo else None
return model_vozilo, sirovi_tekst
def get_vozilo_naziv(self, obj):
"""Vraća samo čisti naziv vozila"""
vozilo, _ = self._parsiraj_spojeni_opis(obj)
return vozilo
def get_opis_cisti(self, obj):
"""Vraća samo čisti tekst opisa kvara"""
_, opis = self._parsiraj_spojeni_opis(obj)
return opis if opis else "Nema dodatnog opisa."

View File

@@ -0,0 +1,52 @@
from django.utils import timezone
from datetime import timedelta
from .models import Dogadaj
def kreiraj_kalendarski_unos(radni_nalog):
"""
Kreira termin u kalendaru na temelju novog radnog naloga.
"""
pocetak = timezone.now()
kraj = pocetak + timedelta(hours=2)
# DOHVAĆANJE VOZILA: Budući da vozilo više nije na Radnom nalogu,
# moramo ga izvući iz povezanog Putnog naloga (ako postoji).
vozilo_obj = None
vozilo_str = "Nije definirano"
if radni_nalog.putni_nalog and radni_nalog.putni_nalog.vozilo:
vozilo_obj = radni_nalog.putni_nalog.vozilo
vozilo_str = str(vozilo_obj)
# Kreiramo Dogadaj
dogadaj = Dogadaj.objects.create(
naslov=f"RN: {radni_nalog.broj_naloga} | {radni_nalog.klijent.naziv}",
opis=f"Vozilo: {vozilo_str}\nOpis kvara: {radni_nalog.opis_kvara}",
tip='servis',
pocetak=pocetak,
kraj=kraj,
klijent=radni_nalog.klijent,
# Ovdje spremamo instancu vozila u polje modela Dogadaj
vozilo=vozilo_obj,
radni_nalog=radni_nalog,
serviser_u_kalendaru=radni_nalog.izvrsitelj
)
return dogadaj
def azuriraj_kalendarski_unos(radni_nalog):
"""
Sinkronizira podatke u kalendaru koristeći metodu modela.
"""
try:
# Pokušaj pronaći postojeći događaj preko related_name 'kalendar_termin'
dogadaj = radni_nalog.kalendar_termin
dogadaj.sync_with_radni_nalog()
except Exception:
# Ako ne postoji (npr. nalog je stariji od uvođenja kalendara), kreiraj ga
from .services import kreiraj_kalendarski_unos
kreiraj_kalendarski_unos(radni_nalog)
def obrisi_kalendarski_unos(radni_nalog):
if hasattr(radni_nalog, 'kalendar_termin'):
radni_nalog.kalendar_termin.delete()

View File

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

View File

@@ -0,0 +1,17 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import DogadajViewSet, MojRasporedView
# Kreiramo router i registriramo naš ViewSet za kalendarske događaje
router = DefaultRouter()
router.register(r'dogadaji', DogadajViewSet, basename='dogadaji')
# Definiramo URL obrasce
urlpatterns = [
# Sve CRUD rute koje router generira (npr. /kalendar/dogadaji/)
path('', include(router.urls)),
# Specijalna ruta za "današnji raspored" servisera
# Dostupna na: /kalendar/moj-raspored/
path('moj-raspored/', MojRasporedView.as_view(), name='moj-raspored'),
]

View File

@@ -0,0 +1,55 @@
from rest_framework import viewsets, generics, filters
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from django.db.models import Q
from .models import Dogadaj
from .serializers import DogadajSerializer
class DogadajViewSet(viewsets.ModelViewSet):
"""
Glavni API za kalendar. Podržava pregled svih termina,
drag-and-drop (patch) i kreiranje novih bilješki.
"""
# VAŽNO: select_related('radni_nalog__izvrsitelj') je ključan
# jer tvoj model koristi @property izvrsitelj.
# Bez ovoga, svaki termin u kalendaru bi radio poseban upit u bazu.
queryset = Dogadaj.objects.all().select_related(
'radni_nalog__izvrsitelj',
'radni_nalog__klijent',
'klijent',
'vozilo',
'serviser_u_kalendaru'
)
serializer_class = DogadajSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['tip', 'klijent', 'vozilo']
ordering = ['pocetak']
def get_queryset(self):
"""
Opcionalno: Ako želiš da serviseri vide samo svoje termine u kalendaru,
otkomentiraj donji dio. Ako dispečer vidi sve, ostavi kako jest.
"""
qs = super().get_queryset()
# if not self.request.user.is_staff:
# return qs.filter(Q(radni_nalog__izvrsitelj=self.request.user) | Q(serviser_u_kalendaru=self.request.user))
return qs
class MojRasporedView(generics.ListAPIView):
"""
Endpoint namijenjen mobilnoj aplikaciji: "Moji zadaci za danas".
Pristup: GET /kalendar/api/moj-raspored/
"""
serializer_class = DogadajSerializer
def get_queryset(self):
user = self.request.user
danas = timezone.now().date()
# Filtriramo samo ono što je dodijeljeno ulogiranom korisniku za danas
return Dogadaj.objects.filter(
Q(pocetak__date=danas) | Q(kraj__date=danas)
).filter(
Q(radni_nalog__izvrsitelj=user) | Q(serviser_u_kalendaru=user)
).select_related('radni_nalog', 'klijent', 'vozilo')

View File

View File

@@ -0,0 +1,41 @@
from django.contrib import admin
from .models import Kupac
@admin.register(Kupac)
class KupacAdmin(admin.ModelAdmin):
# Polja koja se vide u glavnoj tablici
list_display = ('naziv', 'oib', 'grad', 'telefon', 'prikaz_broja_strojeva', 'aktivno')
# Brzo uređivanje "aktivno" statusa direktno iz tablice
list_editable = ('aktivno',)
# Filtriranje s desne strane
list_filter = ('tip', 'aktivno', 'grad')
# Pretraga po ključnim poljima
# OBAVEZNO dodaj search_fields kako bi autocomplete radio u fleet i nalozi
search_fields = ('naziv', 'oib', 'email', 'grad')
# Organizacija forme za unos
fieldsets = (
('Osnovni podaci', {
'fields': (('naziv', 'tip'), 'oib')
}),
('Kontakt i lokacija', {
'fields': ('email', 'telefon', 'adresa', ('grad', 'postanski_broj'))
}),
('Dodatno', {
'fields': ('napomena', 'aktivno'),
'classes': ('collapse',)
}),
)
# Čitanje sistemskih podataka bez mogućnosti izmjene
readonly_fields = ('datum_kreiranja',)
def prikaz_broja_strojeva(self, obj):
"""Prikazuje broj strojeva direktno u listi kupaca"""
# Koristimo property koji si već definirao u modelu
return obj.broj_strojeva
prikaz_broja_strojeva.short_description = 'Strojevi'

View File

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

View File

View File

@@ -0,0 +1,8 @@
from typing import Any
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def handle(self, *args: Any, **options: Any):
print("hello world")

View File

@@ -0,0 +1,75 @@
import random
from django.core.management.base import BaseCommand
from faker import Faker
from kupci.models import Kupac
class Command(BaseCommand):
help = 'Popunjava bazu podataka s testnim kupcima spremnim za logistički modul'
def add_arguments(self, parser):
parser.add_argument(
'total',
type=int,
help='Broj kupaca koje želiš kreirati',
default=10
)
def handle(self, *args, **kwargs):
total = kwargs['total']
fake = Faker(['hr_HR'])
self.stdout.write(self.style.MIGRATE_HEADING(f'🚀 Kreiram {total} testnih kupaca...'))
# Kontrolirani skup gradova u nominativu za savršen ispis relacija i odredišta u logistici
hr_gradovi = [
{'grad': 'Zagreb', 'pbr': '10000'},
{'grad': 'Karlovac', 'pbr': '47000'},
{'grad': 'Ozalj', 'pbr': '47280'},
{'grad': 'Split', 'pbr': '21000'},
{'grad': 'Rijeka', 'pbr': '51000'},
{'grad': 'Osijek', 'pbr': '31000'},
{'grad': 'Varaždin', 'pbr': '42000'},
{'grad': 'Zadar', 'pbr': '23000'},
{'grad': 'Velika Gorica', 'pbr': '10410'},
{'grad': 'Sisak', 'pbr': '44000'}
]
created_count = 0
for _ in range(total):
tip = random.choice(['pravno', 'fizicko'])
if tip == 'pravno':
naziv = fake.company()
else:
naziv = fake.name()
# Generiramo OIB (točno 11 znamenki u string formatu)
oib = "".join([str(random.randint(0, 9)) for _ in range(11)])
# Odabiremo grad i poštanski broj u paru kako bi podaci imali smisla
lokacija = random.choice(hr_gradovi)
# Generiramo telefonski broj koji je čist i siguran za tel: linkove u Astru
pozivni = random.randint(1, 9)
telefon = f"+385 {pozivni} {random.randint(100, 999)}-{random.randint(1000, 9999)}"
try:
# Upotrebljavamo create, a jedinstvenost OIB-a hvatamo u iznimci
Kupac.objects.create(
naziv=naziv,
oib=oib,
email=fake.email(),
telefon=telefon,
adresa=fake.street_address(),
grad=lokacija['grad'], # Usklađeno: čisti nominativ (npr. 'Karlovac')
postanski_broj=lokacija['pbr'], # Usklađeno: odgovarajući poštanski broj
tip=tip,
napomena="Automatski generiran testni kupac usklađen s modulom operativne logistike.",
aktivno=True
)
created_count += 1
except Exception as e:
# Ako OIB slučajno kolidira (unutar unique=True), preskoči i nastavi generirati sljedećeg
continue
self.stdout.write(self.style.SUCCESS(f'✅ Uspješno dodano {created_count} novih kupaca u bazu!'))

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.5 on 2026-05-22 03:52
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Kupac',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naziv', models.CharField(help_text='Ime i prezime ili puni naziv tvrtke', max_length=255)),
('oib', models.CharField(blank=True, max_length=11, null=True, unique=True, verbose_name='OIB')),
('email', models.EmailField(blank=True, max_length=254)),
('telefon', models.CharField(blank=True, max_length=50)),
('adresa', models.CharField(blank=True, max_length=255)),
('grad', models.CharField(blank=True, max_length=100)),
('postanski_broj', models.CharField(blank=True, max_length=10)),
('tip', models.CharField(choices=[('pravno', 'Pravna osoba (Tvrtka)'), ('fizicko', 'Fizička osoba')], default='pravno', max_length=10)),
('napomena', models.TextField(blank=True, help_text='Interni podaci o kupcu')),
('datum_kreiranja', models.DateTimeField(auto_now_add=True)),
('aktivno', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Kupac',
'verbose_name_plural': 'Kupci',
'ordering': ['naziv'],
},
),
]

View File

View File

@@ -0,0 +1,49 @@
from django.db import models
class Kupac(models.Model):
TIP_KUPCA = [
('pravno', 'Pravna osoba (Tvrtka)'),
('fizicko', 'Fizička osoba'),
]
naziv = models.CharField(
max_length=255,
help_text="Ime i prezime ili puni naziv tvrtke"
)
oib = models.CharField(
max_length=11,
unique=True,
null=True,
blank=True,
verbose_name="OIB"
)
email = models.EmailField(blank=True)
telefon = models.CharField(max_length=50, blank=True)
adresa = models.CharField(max_length=255, blank=True)
grad = models.CharField(max_length=100, blank=True)
postanski_broj = models.CharField(max_length=10, blank=True)
tip = models.CharField(
max_length=10,
choices=TIP_KUPCA,
default='pravno'
)
napomena = models.TextField(blank=True, help_text="Interni podaci o kupcu")
# Sistemski podaci
datum_kreiranja = models.DateTimeField(auto_now_add=True)
aktivno = models.BooleanField(default=True)
class Meta:
verbose_name = "Kupac"
verbose_name_plural = "Kupci"
ordering = ['naziv']
def __str__(self):
return self.naziv
@property
def broj_strojeva(self):
"""Vraća broj strojeva povezanih s ovim kupcem"""
return self.strojevi.count()

View File

@@ -0,0 +1,73 @@
from rest_framework import serializers
from .models import Kupac
from fleet.models import Stroj
from fleet.serializers import StrojSerializer
class KupacSerializer(serializers.ModelSerializer):
"""
Serializer za listu kupaca.
Fokus je na brzini i osnovnim kontaktnim podacima.
"""
# Izračunato polje iz modela (@property broj_strojeva)
broj_strojeva = serializers.IntegerField(read_only=True)
class Meta:
model = Kupac
fields = [
'id',
'naziv',
'grad',
'telefon',
'email',
'tip',
'aktivno',
'broj_strojeva' # Korisno za prikaz u tablici (npr. "5 strojeva")
]
class KupacDetaljiSerializer(serializers.ModelSerializer):
# Ugniježđeni strojevi - prikazujemo puni objekt, ne samo ID
strojevi = StrojSerializer(many=True, read_only=True)
# Dodatna polja koja nisu u bazi, ali su korisna za Astro frontend
ukupan_broj_strojeva = serializers.IntegerField(source='broj_strojeva', read_only=True)
class Meta:
model = Kupac
fields = [
'id',
'naziv',
'oib',
'email',
'telefon',
'adresa',
'grad',
'postanski_broj',
'tip',
'napomena',
'aktivno',
'datum_kreiranja',
'ukupan_broj_strojeva',
'strojevi' # Lista svih strojeva s punim detaljima
]
class KupacListaSerializer(serializers.ModelSerializer):
"""
Optimizirani serializer za prikaz u tablici (listi).
Vraća samo osnovne podatke potrebne za pregled svih klijenata.
"""
# Dohvaćamo property iz modela koji broji strojeve
broj_strojeva = serializers.IntegerField(read_only=True)
class Meta:
model = Kupac
fields = [
'id',
'naziv',
'grad',
'oib',
'telefon',
'tip',
'aktivno',
'broj_strojeva'
]

View File

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

14
001.BACKEND/kupci/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import KupacViewSet
# Kreiramo router i registriramo naš ViewSet
# 'r' ispred stringa označava raw string (dobra praksa za regex/putanje)
router = DefaultRouter()
router.register(r'svi', KupacViewSet, basename='kupac')
# Definiramo URL obrasce
urlpatterns = [
# Sve rute koje router generira (npr. /api/kupci/ i /api/kupci/{id}/)
path('', include(router.urls)),
]

View File

@@ -0,0 +1,35 @@
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Kupac
from .serializers import KupacSerializer, KupacDetaljiSerializer, KupacListaSerializer
class KupacViewSet(viewsets.ModelViewSet):
"""
API endpoint koji omogućuje pregled, pretragu i uređivanje kupaca.
"""
# Optimizacija: prefetch_related smanjuje broj upita jer odmah vuče strojeve
queryset = Kupac.objects.all().prefetch_related('strojevi')
serializer_class = KupacSerializer
# Dodajemo filter backende
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter
]
# 1. Točno filtriranje (npr. ?tip=pravno)
filterset_fields = ['tip', 'aktivno', 'grad']
# 2. Pretraga (npr. ?search=gradnja)
# Pretražuje naziv ili OIB
search_fields = ['naziv', 'oib']
# 3. Sortiranje (npr. ?ordering=-datum_kreiranja)
ordering_fields = ['naziv', 'datum_kreiranja']
ordering = ['naziv'] # Defaultno sortiranje
def get_serializer_class(self):
if self.action == 'retrieve': # Ako se gleda pojedinačni kupac
return KupacDetaljiSerializer # Onaj s ugniježđenim strojevima
return KupacListaSerializer # Obični bez strojeva

22
001.BACKEND/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

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)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

View File

@@ -0,0 +1,23 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import CustomUser
class CustomUserAdmin(UserAdmin):
# 🚀 POPRAVLJENO: Zamijenjen 'is_serviser' s 'uloga' u stupcima tablice
list_display = ['email', 'first_name', 'last_name', 'telefon', 'oib', 'uloga', 'is_staff']
# Ako filtriraš korisnike na desnoj strani admina, ažuriraj i to:
list_filter = ['uloga', 'is_staff', 'is_active']
# 🚀 POPRAVLJENO: Dodavanje polja u sekciju za uređivanje postojećeg korisnika
fieldsets = UserAdmin.fieldsets + (
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
)
# 🚀 POPRAVLJENO: Dodavanje polja u formu za kreiranje novog korisnika
add_fieldsets = UserAdmin.add_fieldsets + (
('Dodatni podaci tvrtke', {'fields': ('telefon', 'oib', 'uloga')}),
)
# Registracija modela i tvoje prilagođene admin klase
admin.site.register(CustomUser, CustomUserAdmin)

Some files were not shown because too many files have changed in this diff Show More