prije gemini provjere
206
.gitignore
vendored
Normal 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
@@ -0,0 +1,10 @@
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
db.sqlite3
|
||||
.env
|
||||
staticfiles/
|
||||
media/
|
||||
4
001.BACKEND/.env.example
Normal 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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/env/Scripts/python.exe",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}"
|
||||
]
|
||||
}
|
||||
50
001.BACKEND/Dockerfile
Normal 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"]
|
||||
|
||||
3
001.BACKEND/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
16
001.BACKEND/core/asgi.py
Normal 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()
|
||||
14
001.BACKEND/core/celery.py
Normal 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()
|
||||
182
001.BACKEND/core/settings.py
Normal 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
@@ -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
@@ -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()
|
||||
0
001.BACKEND/fleet/__init__.py
Normal file
90
001.BACKEND/fleet/admin.py
Normal 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'
|
||||
5
001.BACKEND/fleet/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FleetConfig(AppConfig):
|
||||
name = 'fleet'
|
||||
103
001.BACKEND/fleet/management/commands/populate_stroj.py
Normal 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.'))
|
||||
86
001.BACKEND/fleet/management/commands/populate_vozila.py
Normal 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.'))
|
||||
53
001.BACKEND/fleet/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
001.BACKEND/fleet/migrations/__init__.py
Normal file
88
001.BACKEND/fleet/models.py
Normal 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})"
|
||||
115
001.BACKEND/fleet/serializers.py
Normal 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
|
||||
3
001.BACKEND/fleet/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
001.BACKEND/fleet/urls.py
Normal 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)),
|
||||
]
|
||||
72
001.BACKEND/fleet/views.py
Normal 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
|
||||
0
001.BACKEND/kalendar/__init__.py
Normal file
81
001.BACKEND/kalendar/admin.py
Normal 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',)
|
||||
5
001.BACKEND/kalendar/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class KalendarConfig(AppConfig):
|
||||
name = 'kalendar'
|
||||
42
001.BACKEND/kalendar/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
001.BACKEND/kalendar/migrations/__init__.py
Normal file
61
001.BACKEND/kalendar/models.py
Normal 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}"
|
||||
77
001.BACKEND/kalendar/serializers.py
Normal 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."
|
||||
52
001.BACKEND/kalendar/services.py
Normal 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()
|
||||
3
001.BACKEND/kalendar/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
001.BACKEND/kalendar/urls.py
Normal 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'),
|
||||
]
|
||||
55
001.BACKEND/kalendar/views.py
Normal 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')
|
||||
0
001.BACKEND/kupci/__init__.py
Normal file
41
001.BACKEND/kupci/admin.py
Normal 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'
|
||||
5
001.BACKEND/kupci/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class KupciConfig(AppConfig):
|
||||
name = 'kupci'
|
||||
0
001.BACKEND/kupci/management/__init__.py
Normal file
0
001.BACKEND/kupci/management/commands/__init__.py
Normal file
8
001.BACKEND/kupci/management/commands/hello_world.py
Normal 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")
|
||||
75
001.BACKEND/kupci/management/commands/populate_kupci.py
Normal 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!'))
|
||||
36
001.BACKEND/kupci/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
001.BACKEND/kupci/migrations/__init__.py
Normal file
49
001.BACKEND/kupci/models.py
Normal 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()
|
||||
73
001.BACKEND/kupci/serializers.py
Normal 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'
|
||||
]
|
||||
3
001.BACKEND/kupci/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
001.BACKEND/kupci/urls.py
Normal 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)),
|
||||
]
|
||||
35
001.BACKEND/kupci/views.py
Normal 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
@@ -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()
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
001.BACKEND/media/radni-nalozi/RN-2026-0001/rn_RN-2026-0001.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
001.BACKEND/media/radni-nalozi/RN-2026-0002/rn_RN-2026-0002.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
001.BACKEND/media/radni-nalozi/RN-2026-0003/rn_RN-2026-0003.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
001.BACKEND/media/radni-nalozi/RN-2026-0004/rn_RN-2026-0004.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
BIN
001.BACKEND/media/radni-nalozi/RN-2026-0005/rn_RN-2026-0005.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
0
001.BACKEND/operations/__init__.py
Normal file
112
001.BACKEND/operations/admin.py
Normal 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"
|
||||
5
001.BACKEND/operations/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OperationsConfig(AppConfig):
|
||||
name = 'operations'
|
||||
@@ -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.'))
|
||||
88
001.BACKEND/operations/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
001.BACKEND/operations/migrations/__init__.py
Normal file
98
001.BACKEND/operations/mixins.py
Normal 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)
|
||||
248
001.BACKEND/operations/models.py
Normal 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}"
|
||||
110
001.BACKEND/operations/serializers.py
Normal 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
|
||||
122
001.BACKEND/operations/services.py
Normal 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()
|
||||
0
001.BACKEND/operations/tasks.py
Normal file
3
001.BACKEND/operations/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
13
001.BACKEND/operations/urls.py
Normal 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)),
|
||||
]
|
||||
155
001.BACKEND/operations/views.py
Normal 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)
|
||||
BIN
001.BACKEND/requirements.txt
Normal file
BIN
001.BACKEND/test_assets/kvar.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
0
001.BACKEND/users/__init__.py
Normal file
23
001.BACKEND/users/admin.py
Normal 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)
|
||||