prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

39
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,39 @@
name: ERP CI Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:16-alpine
env:
POSTGRES_DB: erp_db
POSTGRES_PASSWORD: password
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r backend/requirements.txt
pip install pytest pytest-django
- name: Run Unit Tests
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db
run: |
# Pokrećemo testove iz našeg backend direktorija
pytest backend/modules/invoicing/tests/test_services.py
- name: Run Task Management Tests
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db
run: |
pytest backend/modules/task_management/tests/test_services.py

View File

@@ -0,0 +1,8 @@
---
description: Describe when these instructions should be loaded by the agent based on task context
# applyTo: 'Describe when these instructions should be loaded by the agent based on task context' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file
---
<!-- Tip: Use /create-instructions in chat to generate content with agent assistance -->
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.

211
.gitignore vendored Normal file
View File

@@ -0,0 +1,211 @@
# ---> 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/
# Docker
docker-compose.yml.backup
docker-compose.override.yml
docker-compose.override.yml.backup

20
backend/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
env/
venv/
.venv/
__pycache__/
__pycache__
*.pyc
*.pyo
*.pyd
.git/
.gitignore
db.sqlite3
.env
staticfiles/
media/
.vscode/
# Docker higijena (Dodano)
Dockerfile
.dockerignore
docker-compose*.yml

35
backend/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# --- Stage 1: Builder (Ovdje se događa kompajliranje) ---
FROM python:3.11-slim AS builder
# Instaliraj alate za build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
# Instaliraj sve u /install direktorij
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# --- Stage 2: Final (Samo čisti runtime) ---
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
# 1. Kreiraj korisnika i postavi dozvole dok si još ROOT
RUN useradd -m appuser && \
mkdir -p /app/staticfiles && \
chown -R appuser:appuser /app
# 2. Sada prijeđi na ne-root korisnika
USER appuser
RUN chmod +x entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

0
backend/core/__init__.py Normal file
View File

View File

@@ -0,0 +1,34 @@
import uuid
from django.db import models
class BaseModel(models.Model):
"""
Industrijski standardni model za ERP entitete.
Svi modeli u našim modulima nasljeđuju ovu klasu.
"""
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
help_text="Unikatni identifikator entiteta (UUID)."
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Vrijeme kreiranja"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Vrijeme zadnje izmjene"
)
is_active = models.BooleanField(
default=True,
verbose_name="Aktivan zapis"
)
class Meta:
abstract = True # Ovo je ključ: Django ne kreira tablicu za ovu klasu
ordering = ['-created_at']
def __str__(self):
# Defaultni prikaz koji olakšava debugging u adminu
return f"{self.__class__.__name__} ({str(self.id)[:8]})"

15
backend/core/celery.py Normal file
View File

@@ -0,0 +1,15 @@
# backend/core/celery.py
import os
from celery import Celery
# Postavi Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
app.config_from_object('django.conf:settings', namespace='CELERY')
# Automatski pronalazi taskove u svim 'modules.*'
app.autodiscover_tasks()

View File

View File

@@ -0,0 +1,385 @@
import random
import sys
import uuid
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from modules.crm.models import Client
from modules.fleet.models import (
Vehicle,
WorkOrder,
VehicleNotification,
VehicleServiceRecord,
VehicleServicePhoto,
)
from modules.invoicing.models import Invoice, InvoiceItem, InvoiceTransaction
from modules.task_management.models import Task
class Command(BaseCommand):
help = (
"Generira demo podatke za sve domenske modele. "
"Ako --count nije zadan, komanda će u terminalu pitati količinu po modelu."
)
model_labels = [
("clients", "CRM klijenti"),
("users", "korisnici"),
("vehicles", "vozila"),
("work_orders", "radni/putni nalozi"),
("service_records", "servisni zapisi"),
("notifications", "notifikacije"),
("tasks", "task zapisi"),
("invoices", "fakture"),
("invoice_items", "stavke fakture"),
("invoice_transactions", "transakcije fakture"),
("service_photos", "fotografije servisa"),
]
def add_arguments(self, parser):
parser.add_argument(
"--count",
type=int,
default=None,
help="Količina zapisa za svaki model. Ako nije zadano, pita se po modelu.",
)
def handle(self, *args, **options):
global_count = options["count"]
if global_count is not None and global_count < 0:
raise CommandError("--count mora biti >= 0")
counts = self._resolve_counts(global_count)
self.stdout.write(self.style.MIGRATE_HEADING("Pokrećem generiranje demo podataka..."))
with transaction.atomic():
clients = self._seed_clients(counts["clients"])
users = self._seed_users(counts["users"], clients)
vehicles = self._seed_vehicles(counts["vehicles"], users)
work_orders = self._seed_work_orders(counts["work_orders"], vehicles, users)
tasks = self._seed_tasks(counts["tasks"], users, vehicles)
service_records = self._seed_service_records(counts["service_records"], users, tasks)
self._seed_notifications(counts["notifications"], users)
invoices = self._seed_invoices(counts["invoices"], users, clients)
self._seed_invoice_items(counts["invoice_items"], invoices)
self._seed_invoice_transactions(counts["invoice_transactions"], invoices)
self._seed_service_photos(counts["service_photos"], service_records, users)
self.stdout.write(self.style.SUCCESS("Generiranje završeno."))
def _resolve_counts(self, global_count):
counts = {}
for key, label in self.model_labels:
counts[key] = self._ask_count(label, global_count)
return counts
def _ask_count(self, label, global_count):
if global_count is not None:
return global_count
if not sys.stdin.isatty():
raise CommandError(
"Detektiran je non-interactive terminal. "
"Pokreni s --count (npr. `python manage.py seed_all_models --count 10`) "
"ili interaktivno s TTY (`docker compose exec -it backend python manage.py seed_all_models`)."
)
while True:
raw = input(f"Koliko zapisa želiš generirati za {label}? [0]: ").strip()
if raw == "":
return 0
try:
value = int(raw)
if value < 0:
self.stdout.write(self.style.WARNING("Vrijednost mora biti >= 0."))
continue
return value
except ValueError:
self.stdout.write(self.style.WARNING("Unesi cijeli broj."))
def _seed_clients(self, count):
created = []
for i in range(count):
suffix = uuid.uuid4().hex[:8]
client = Client.objects.create(
name=f"Klijent {suffix}",
client_type=random.choice(["legal", "individual"]),
tax_id=f"{random.randint(10000000000, 99999999999)}",
email=f"client.{suffix}@example.test",
phone=f"+38591{random.randint(1000000, 9999999)}",
address=f"Ulica {random.randint(1, 120)}",
city=random.choice(["Zagreb", "Split", "Rijeka", "Osijek"]),
postal_code=str(random.randint(10000, 53000)),
)
created.append(client)
if created:
self.stdout.write(self.style.SUCCESS(f"Client: kreirano {len(created)}"))
return created or list(Client.objects.all())
def _seed_users(self, count, clients):
User = get_user_model()
created = []
clients_pool = clients or list(Client.objects.all())
for i in range(count):
suffix = uuid.uuid4().hex[:8]
is_kupac = random.choice([True, False])
client_profile = random.choice(clients_pool) if is_kupac and clients_pool else None
user = User.objects.create_user(
email=f"user.{suffix}@example.test",
password="password123",
first_name=f"Ime{i + 1}",
last_name=f"Prezime{i + 1}",
is_serviser=random.choice([True, False]),
is_team_member=random.choice([True, False]),
is_kupac=is_kupac if client_profile else False,
client_profile=client_profile,
telefon=f"+38595{random.randint(1000000, 9999999)}",
is_verified=random.choice([True, False]),
)
created.append(user)
if created:
self.stdout.write(self.style.SUCCESS(f"CustomUser: kreirano {len(created)}"))
return created or list(User.objects.all())
def _seed_vehicles(self, count, users):
created = []
users_pool = [u for u in users if getattr(u, "is_serviser", False)]
assigned_servicer_ids = set(
Vehicle.objects.exclude(assigned_servicer__isnull=True).values_list("assigned_servicer_id", flat=True)
)
clients_pool = list(Client.objects.all())
for _ in range(count):
reg = f"ZG-{random.randint(1000, 9999)}-{uuid.uuid4().hex[:2].upper()}"
available_servicers = [u for u in users_pool if u.id not in assigned_servicer_ids]
assigned = random.choice(available_servicers) if available_servicers and random.choice([True, False]) else None
asset_type = random.choice(["vehicle", "crane"])
# 40% vozila su klijentova (is_company_vehicle=False, client=random klijent)
is_company = random.random() > 0.4
client_obj = None if is_company else (random.choice(clients_pool) if clients_pool else None)
vehicle = Vehicle.objects.create(
asset_type=asset_type,
registration_number=reg,
crane_serial_number=f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None,
make=random.choice(["VW", "Ford", "Mercedes", "Renault"]),
model=random.choice(["Model A", "Model B", "Model C"]),
year=random.randint(2008, timezone.now().year),
vin=f"VIN{uuid.uuid4().hex[:16].upper()}",
current_mileage=random.randint(10000, 250000),
service_interval_km=random.choice([10000, 15000, 20000]),
assigned_servicer=assigned,
is_company_vehicle=is_company,
client=client_obj,
)
if assigned:
assigned_servicer_ids.add(assigned.id)
created.append(vehicle)
if created:
self.stdout.write(self.style.SUCCESS(f"Vehicle: kreirano {len(created)} ({sum(1 for v in created if not v.is_company_vehicle)} klijentskih)"))
return created or list(Vehicle.objects.all())
def _seed_work_orders(self, count, vehicles, users):
created = []
if not vehicles or not users:
return created
for _ in range(count):
vehicle = random.choice(vehicles)
creator = random.choice(users)
start_mileage = max(0, vehicle.current_mileage - random.randint(0, 2000))
is_closed = random.choice([True, False])
end_mileage = start_mileage + random.randint(1, 600) if is_closed else None
status = "closed" if is_closed else "open"
order = WorkOrder.objects.create(
vehicle=vehicle,
creator=creator,
start_mileage=start_mileage,
end_mileage=end_mileage,
purpose=random.choice(["Redovni pregled", "Hitna intervencija", "Kontrola"]),
notes="Auto-generirani demo nalog.",
status=status,
)
created.append(order)
if created:
self.stdout.write(self.style.SUCCESS(f"WorkOrder: kreirano {len(created)}"))
return created or list(WorkOrder.objects.all())
def _seed_service_records(self, count, users, tasks):
created = []
if not users:
return created
tasks_pool = tasks or list(Task.objects.filter(is_active=True))
tasks_with_vehicle = [t for t in tasks_pool if t.vehicle_id]
for _ in range(count):
if not tasks_with_vehicle:
break
task = random.choice(tasks_with_vehicle)
vehicle = task.vehicle
performed_by = random.choice(users)
mileage = max(vehicle.current_mileage, random.randint(5000, 280000))
record = VehicleServiceRecord.objects.create(
vehicle=vehicle,
task=task,
performed_by=performed_by,
description=random.choice(
["Zamjena ulja i filtera", "Kočnice i diskovi", "Dijagnostika motora", "Generalni servis"]
),
parts="Filter ulja, ulje 5W30",
cost=Decimal(str(random.randint(50, 900))),
mileage=mileage,
next_service_due_at=mileage + random.choice([8000, 10000, 12000]),
)
created.append(record)
if created:
self.stdout.write(self.style.SUCCESS(f"VehicleServiceRecord: kreirano {len(created)}"))
return created or list(VehicleServiceRecord.objects.all())
def _seed_notifications(self, count, users):
created = []
users_pool = users or list(get_user_model().objects.all())
if not users_pool:
return created
for _ in range(count):
notification = VehicleNotification.objects.create(
recipient=random.choice(users_pool),
title=random.choice(["Novi nalog", "Servis uskoro", "Status ažuriran"]),
message="Auto-generirana notifikacija za testiranje.",
level=random.choice(["info", "warning", "critical"]),
is_sent=random.choice([True, False]),
is_read=random.choice([True, False]),
)
created.append(notification)
if created:
self.stdout.write(self.style.SUCCESS(f"VehicleNotification: kreirano {len(created)}"))
return created
def _seed_tasks(self, count, users, vehicles):
created = []
users_pool = users or list(get_user_model().objects.all())
# Preferiramo dizalice (asset_type='crane') za taskove
cranes_pool = [v for v in vehicles if getattr(v, 'asset_type', None) == 'crane']
vehicles_pool = cranes_pool or vehicles or list(Vehicle.objects.filter(asset_type='crane'))
for i in range(count):
status = random.choice(["aktivan", "servis"])
vehicle = random.choice(vehicles_pool) if vehicles_pool else None
task = Task.objects.create(
title=f"Task {uuid.uuid4().hex[:6]}",
description="Auto-generirani zadatak.",
status=status,
assigned_to=random.choice(users_pool) if users_pool else None,
vehicle=vehicle,
)
created.append(task)
if created:
self.stdout.write(self.style.SUCCESS(f"Task: kreirano {len(created)}"))
return created
def _seed_invoices(self, count, users, clients):
created = []
users_pool = users or list(get_user_model().objects.all())
clients_pool = clients or list(Client.objects.all())
if not users_pool or not clients_pool:
return created
for _ in range(count):
amount = Decimal(str(random.randint(100, 5000)))
invoice = Invoice.objects.create(
creator=random.choice(users_pool),
client=random.choice(clients_pool),
amount=amount,
currency="EUR",
tax_rate=Decimal("25.00"),
status=random.choice(["draft", "sent", "paid", "cancelled"]),
due_date=timezone.now().date() + timedelta(days=random.randint(7, 45)),
)
created.append(invoice)
if created:
self.stdout.write(self.style.SUCCESS(f"Invoice: kreirano {len(created)}"))
return created or list(Invoice.objects.all())
def _seed_invoice_items(self, count, invoices):
created = []
invoices_pool = invoices or list(Invoice.objects.all())
if not invoices_pool:
return created
for _ in range(count):
item = InvoiceItem.objects.create(
invoice=random.choice(invoices_pool),
description=random.choice(["Servisna usluga", "Rezervni dio", "Terenski rad"]),
quantity=Decimal(str(random.randint(1, 5))),
unit_price=Decimal(str(random.randint(20, 700))),
)
created.append(item)
if created:
self.stdout.write(self.style.SUCCESS(f"InvoiceItem: kreirano {len(created)}"))
return created
def _seed_invoice_transactions(self, count, invoices):
created = []
invoices_pool = invoices or list(Invoice.objects.all())
if not invoices_pool:
return created
for _ in range(count):
invoice = random.choice(invoices_pool)
txn = InvoiceTransaction.objects.create(
invoice=invoice,
invoice_number=invoice.invoice_number,
amount=invoice.amount,
payment_date=timezone.now().date() - timedelta(days=random.randint(0, 30)),
)
created.append(txn)
if created:
self.stdout.write(self.style.SUCCESS(f"InvoiceTransaction: kreirano {len(created)}"))
return created
def _seed_service_photos(self, count, service_records, users):
created = []
records_pool = service_records or list(VehicleServiceRecord.objects.all())
users_pool = users or list(get_user_model().objects.all())
if not records_pool:
return created
tiny_gif = (
b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00"
b"\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00"
b"\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b"
)
for i in range(count):
photo = VehicleServicePhoto(
service_record=random.choice(records_pool),
uploaded_by=random.choice(users_pool) if users_pool else None,
description="Auto-generirana servisna fotografija.",
)
photo.image.save(f"service_photo_{i}_{uuid.uuid4().hex[:6]}.gif", ContentFile(tiny_gif), save=True)
created.append(photo)
if created:
self.stdout.write(self.style.SUCCESS(f"VehicleServicePhoto: kreirano {len(created)}"))
return created

View File

@@ -0,0 +1,99 @@
import random
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from modules.fleet.models import Vehicle
class Command(BaseCommand):
help = "Kreira/demo-popunjava fleet assete: default 12 dizalica i 2 vozila."
def add_arguments(self, parser):
parser.add_argument(
"--cranes",
type=int,
default=12,
help="Broj dizalica za kreirati/ažurirati (default: 12).",
)
parser.add_argument(
"--vehicles",
type=int,
default=2,
help="Broj vozila za kreirati/ažurirati (default: 2).",
)
def handle(self, *args, **options):
cranes_count = options["cranes"]
vehicles_count = options["vehicles"]
if cranes_count < 0 or vehicles_count < 0:
raise CommandError("--cranes i --vehicles moraju biti >= 0")
with transaction.atomic():
created_cranes, updated_cranes = self._seed_assets(
asset_type="crane",
count=cranes_count,
reg_prefix="CRN",
make="Liebherr",
model="LTM",
mileage_start=12000,
service_interval=8000,
)
created_vehicles, updated_vehicles = self._seed_assets(
asset_type="vehicle",
count=vehicles_count,
reg_prefix="VEH",
make="Mercedes",
model="Sprinter",
mileage_start=45000,
service_interval=15000,
)
self.stdout.write(
self.style.SUCCESS(
"Fleet seed završen: "
f"crane created={created_cranes}, updated={updated_cranes}; "
f"vehicle created={created_vehicles}, updated={updated_vehicles}"
)
)
def _seed_assets(
self,
*,
asset_type,
count,
reg_prefix,
make,
model,
mileage_start,
service_interval,
):
created = 0
updated = 0
for index in range(1, count + 1):
reg = f"ZG-{reg_prefix}-{index:02d}"
vin = f"VIN-{reg_prefix}-{index:02d}"
defaults = {
"asset_type": asset_type,
"crane_serial_number": f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None,
"make": make,
"model": f"{model}-{index:02d}",
"year": 2020 + (index % 4),
"vin": vin,
"current_mileage": mileage_start + (index * 1000),
"service_interval_km": service_interval,
"is_company_vehicle": True,
"client": None,
"assigned_servicer": None,
"is_active": True,
}
_, was_created = Vehicle.objects.update_or_create(
registration_number=reg,
defaults=defaults,
)
if was_created:
created += 1
else:
updated += 1
return created, updated

View File

@@ -0,0 +1,14 @@
from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
"""
Bazni serijalizator koji automatski uključuje ID i vremenske žigove.
Svi naši moduli će koristiti ovaj serializer umjesto DRF-ovog.
"""
id = serializers.UUIDField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
class Meta:
# Ovo će biti nadjačano u konkretnim serializerima
fields = ['id', 'created_at', 'updated_at']

View File

@@ -0,0 +1,8 @@
# backend/core/settings/__init__.py
import os
# Provjeravamo varijablu okruženja koju Docker šalje
if os.environ.get('DEBUG') == 'False':
from .production import *
else:
from .development import *

View File

@@ -0,0 +1,215 @@
import os
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# --- SIGURNOST I OKRUŽENJE (Docker Environment Variables) ---
# Ako varijabla nije definirana u Dockeru, koristi se fallback (razvojni ključ)
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=br8op0rdwf*ks#sji20&mp2+nwjs_5')
# Docker prosljeđuje string 'True' ili 'False'. Pretvaramo ga u Python Boolean.
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'rest_framework',
'django_extensions',
'rest_framework_simplejwt',
'modules.invoicing',
'modules.task_management',
'core.users.apps.UsersConfig',
'modules.crm',
'modules.fleet'
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Za statiku
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
]
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'
# --- VALIDACIJA LOZINKI ---
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'},
]
AUTH_USER_MODEL = 'users.CustomUser'
# --- INTERNACIONALIZACIJA (Lokalizacija) ---
LANGUAGE_CODE = 'hr' # Postavljeno na hrvatski jezik za ERP/Zadatke
TIME_ZONE = 'Europe/Zagreb' # Sinkronizirano s našom vremenskom zonom
USE_I18N = True
USE_TZ = True
CORS_ALLOW_CREDENTIALS = True
# U bazi definiramo default, a u prod/dev override-amo
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:4321').split(',')
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',
]
# Dopušta preflight zahtjeve unutar lokalne mreže (npr. s Docker mreže na tvoje računalo)
CORS_ALLOW_PRIVATE_NETWORK = True
CORS_PREFLIGHT_MAX_AGE = 86400
# Dopusti slanje Authorization zaglavlja (JWT Tokena) kroz mrežne zahtjeve
CORS_ALLOW_CREDENTIALS = True
# --- REST FRAMEWORK & JWT POSTAVKE ---
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
# 'rest_framework.permissions.AllowAny',
),
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True, # Dodano
'AUTH_HEADER_TYPES': ('Bearer',),
}
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'super-tajni-dugacki-kljuc-koji-ces-promijeniti')
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ==============================================================================
# 🔥 CELERY & REDIS POSTAVKE (Lean Worker Model)
# ==============================================================================
# Čitamo REDIS_URL iz okruženja (Docker prosljeđuje 'redis://redis:6379/0').
# Ako pokrećeš izvan Dockera, koristi localhost kao fallback.
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': REDIS_URL, # Koristi isti Redis kontejner i bazu /0
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'PARSER_KWARGS': {'decode_responses': True},
}
}
}
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
# URL kojim će preglednik dohvaćati statiku
STATIC_URL = '/static/'
# Direktorij gdje će 'collectstatic' skupljati sve statičke datoteke (za produkciju)
STATIC_ROOT = BASE_DIR / '/app/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# ==============================================================================
# 🔥 Paperless postavke
# ==============================================================================
PAPERLESS_BASE_URL = os.environ.get('PAPERLESS_BASE_URL', 'http://paperless:8000') # paperless na drugoj docker compose instanci
PAPERLESS_API_TOKEN = os.environ.get('PAPERLESS_API_TOKEN', '144b62542009c1c1a5a296b16601b4ab488f6a01')
# ==============================================================================
# 🔥 Email postavke (Google)
# ==============================================================================
# SMTP Konfiguracija
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
# Čitanje iz .env varijabli
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', 'tvoj.email@gmail.com')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', 'xxxx-xxxx-xxxx-xxxx')
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'tvoj.email@gmail.com')
# ==============================================================================
# 🔥 PUSHER CONFIGURATION
# ==============================================================================
# Pusher Configuration
PUSHER_APP_ID = os.environ.get('PUSHER_APP_ID')
PUSHER_KEY = os.environ.get('PUSHER_KEY')
PUSHER_SECRET = os.environ.get('PUSHER_SECRET')
PUSHER_CLUSTER = os.environ.get('PUSHER_CLUSTER', default='mt1')
# Cache za fallback mehanizam
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
}
}

View File

@@ -0,0 +1,36 @@
from .base import *
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'backend']
# SQLite za brzi lokalni razvoj (nema potrebe za instalacijom Postgresa na laptopu)
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
# }
# }
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST', 'db'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
# CORS za Astro razvojni port (4321)
CORS_ALLOWED_ORIGINS = [
"http://localhost:4321",
"http://127.0.0.1:4321",
]
# Dodajemo Debug Toolbar za analizu performansi
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']
CELERY_TASK_ALWAYS_EAGER = True

View File

@@ -0,0 +1,28 @@
from .base import *
DEBUG = False
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
# PostgreSQL za produkciju (industrijski standard)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST', 'db'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
# Sigurnosne postavke
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Produkcijski CORS (samo domena na kojoj vrti Astro)
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
# Statika i Mediji
STATIC_ROOT = '/app/staticfiles'
MEDIA_ROOT = '/app/media'

33
backend/core/urls.py Normal file
View File

@@ -0,0 +1,33 @@
# backend/core/urls.py
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 = [
path('admin/', admin.site.urls),
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
path('api/crm/', include('modules.crm.urls')),
path('api/invoicing/', include('modules.invoicing.urls')),
path('api/tasks/', include('modules.task_management.urls')),
path('api/fleet/', include('modules.fleet.urls')),
# JWT Auth rute
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# KORISNICI - registriramo ovdje
path('api/users/', include('core.users.urls')),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

View File

@@ -0,0 +1,27 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import CustomUser
@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
model = CustomUser
# Definiramo koja polja se vide u admin listi i formama
list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile')
fieldsets = (
(None, {'fields': ('email', 'password')}),
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence')}),
('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}),
('Važni datumi', {'fields': ('last_login', 'date_joined')}),
)
# 3. Add fieldsets (za kreiranje novog korisnika u adminu)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password', 'is_team_member'),
}),
)
search_fields = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence')
ordering = ('email',)
raw_id_fields = ('client_profile',)

View File

@@ -0,0 +1,7 @@
# backend/core/users/apps.py
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'core.users' # Ovo je putanja do modula
label = 'users' # Ovo je kratko ime koje Django koristi (app_label)
verbose_name = 'User Management'

View File

@@ -0,0 +1,2 @@
from core.management.commands.seed_all_models import Command # noqa: F401

View File

@@ -0,0 +1,2 @@
from core.management.commands.seed_fleet_assets import Command # noqa: F401

View File

@@ -0,0 +1,107 @@
"""
seed_knezljubo.py
Kreira demo klijenta "Knezljubo d.o.o." s jednim fleet vozilom (is_company_vehicle=True)
i jednim vozilom u vlasništvu klijenta (is_company_vehicle=False).
Idempotentna: ponavljanjem ne kreira duplikate (koristi get_or_create).
Uporaba:
python manage.py seed_knezljubo
docker compose exec -it backend python manage.py seed_knezljubo
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from modules.crm.models import Client
from modules.fleet.models import Vehicle
KNEZLJUBO_TAX_ID = "12345678901"
FLEET_VIN = "VIN_FLEET_KNEZLJUBO001"
CLIENT_VIN = "VIN_CLIENT_KNEZLJUBO002"
class Command(BaseCommand):
help = "Kreira klijenta Knezljubo s fleet vozilom i vozilom u vlasništvu klijenta."
def handle(self, *args, **options):
with transaction.atomic():
client = self._seed_client()
fleet_vehicle = self._seed_fleet_vehicle(client)
client_vehicle = self._seed_client_vehicle(client)
self.stdout.write(self.style.SUCCESS(
f"\nZavršeno!\n"
f" Klijent : {client} (id={client.id})\n"
f" Fleet vozilo : {fleet_vehicle} [is_company_vehicle=True]\n"
f" Klijentovo : {client_vehicle} [is_company_vehicle=False]\n"
))
# ── Klijent ───────────────────────────────────────────────────────────────
def _seed_client(self) -> Client:
client, created = Client.objects.get_or_create(
tax_id=KNEZLJUBO_TAX_ID,
defaults={
"name": "Knezljubo d.o.o.",
"client_type": "legal",
"email": "info@knezljubo.hr",
"phone": "+385912345678",
"address": "Ilica 100",
"city": "Zagreb",
"postal_code": "10000",
},
)
action = "Kreiran" if created else "Već postoji"
self.stdout.write(self.style.SUCCESS(f"Client: {action}{client}"))
return client
# ── Fleet vozilo (in vlasništvu naše tvrtke, ali vezano za klijenta) ──────
def _seed_fleet_vehicle(self, client: Client) -> Vehicle:
"""
Vozilo je dio naše fleet-a (is_company_vehicle=True) ALI je operativno
vezano za klijenta Knezljubo — npr. vozilo koje servisiramo po ugovoru.
"""
vehicle, created = Vehicle.objects.get_or_create(
vin=FLEET_VIN,
defaults={
"registration_number": "ZG-0001-KZ",
"make": "Mercedes",
"model": "Sprinter",
"year": 2021,
"current_mileage": 48500,
"service_interval_km": 15000,
"is_company_vehicle": True, # dio naše flote
"client": client, # operativno vezan za klijenta
},
)
action = "Kreiran" if created else "Već postoji"
self.stdout.write(self.style.SUCCESS(f"Fleet vozilo: {action}{vehicle}"))
return vehicle
# ── Vozilo u vlasništvu klijenta ──────────────────────────────────────────
def _seed_client_vehicle(self, client: Client) -> Vehicle:
"""
Vozilo koje KLIJENT posjeduje — naša tvrtka ga servisira.
is_company_vehicle=False → filter u admin i frontend pokazuje ovo kao klijentovo.
"""
vehicle, created = Vehicle.objects.get_or_create(
vin=CLIENT_VIN,
defaults={
"registration_number": "ZG-0002-KZ",
"make": "VW",
"model": "Crafter",
"year": 2019,
"current_mileage": 112000,
"service_interval_km": 10000,
"is_company_vehicle": False, # klijentovo vozilo
"client": client,
},
)
action = "Kreiran" if created else "Već postoji"
self.stdout.write(self.style.SUCCESS(f"Klijentovo vozilo: {action}{vehicle}"))
return vehicle

View File

@@ -0,0 +1,52 @@
# backend/core/users/management/commands/seed_users.py
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.db import transaction
from modules.crm.models import Client
User = get_user_model()
class Command(BaseCommand):
help = 'Kreira testne korisnike za razvojno okruženje'
def handle(self, *args, **kwargs):
users_to_create = [
{
"email": "serviser@primjer.hr",
"first_name": "Ivan",
"last_name": "Serviser",
"is_serviser": True,
"is_team_member": True,
"password": "password123"
},
{
"email": "kupac@primjer.hr",
"first_name": "Marko",
"last_name": "Kupac",
"is_kupac": True,
"password": "password123"
}
]
with transaction.atomic():
for user_data in users_to_create:
email = user_data.pop('email')
password = user_data.pop('password')
if user_data.get('is_kupac'):
client, _ = Client.objects.get_or_create(name="Testni Klijent DOO")
user_data['client_profile'] = client
# Provjeri postoji li korisnik
if not User.objects.filter(email=email).exists():
# KORISTI MANAGER UMJESTO MODELA
user = User.objects.create_user(
email=email,
password=password,
**user_data
)
self.stdout.write(self.style.SUCCESS(f'Kreiran korisnik: {email}'))
else:
self.stdout.write(self.style.WARNING(f'Korisnik već postoji: {email}'))
self.stdout.write(self.style.SUCCESS('Seeding završen!'))

View File

@@ -0,0 +1,30 @@
from django.contrib.auth.models import BaseUserManager
class CustomUserManager(BaseUserManager):
"""
Custom manager za User model gdje koristimo email kao identifikator.
"""
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError('Email adresa mora biti zadana.')
extra_fields.pop('username', None)
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
extra_fields.setdefault('is_serviser', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser mora imati is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser mora imati is_superuser=True.')
return self.create_user(email, password, **extra_fields)

View File

@@ -0,0 +1,47 @@
# Generated by Django 5.2.15 on 2026-06-29 13:34
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('telefon', models.CharField(blank=True, max_length=20)),
('oib', models.CharField(blank=True, max_length=11, null=True)),
('is_serviser', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.2.15 on 2026-06-29 20:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '__first__'),
('users', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='customuser',
managers=[
],
),
migrations.AddField(
model_name='customuser',
name='client_profile',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='crm.client', verbose_name='Povezani klijent'),
),
migrations.AddField(
model_name='customuser',
name='is_kupac',
field=models.BooleanField(default=False, verbose_name='Kupac'),
),
migrations.AddField(
model_name='customuser',
name='is_verified',
field=models.BooleanField(default=False, verbose_name='Verificiran profil'),
),
migrations.AddField(
model_name='customuser',
name='licence_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Broj licence'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.15 on 2026-06-30 06:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0002_alter_customuser_managers_customuser_client_profile_and_more'),
]
operations = [
migrations.RemoveField(
model_name='customuser',
name='username',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.15 on 2026-07-06 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_remove_customuser_username'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='is_team_member',
field=models.BooleanField(default=False, verbose_name='Član tima'),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_customuser_is_team_member'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='occupation',
field=models.CharField(blank=True, default='', max_length=120, verbose_name='Zanimanje'),
),
migrations.AddField(
model_name='customuser',
name='residence',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Prebivalište'),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_customuser_occupation_residence'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='work_position',
field=models.CharField(blank=True, default='', max_length=120, verbose_name='Na radnom mjestu'),
),
]

View File

@@ -0,0 +1,52 @@
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from .managers import CustomUserManager
class CustomUser(AbstractUser):
# Email koristimo za login, pa mora biti jedinstven
username=None
email = models.EmailField(unique=True)
# ERP Specifična polja
is_serviser = models.BooleanField(default=False) # Razlikovanje uloga
is_kupac = models.BooleanField(default=False, verbose_name="Kupac")
is_team_member = models.BooleanField(default=False, verbose_name="Član tima")
# Dodatne informacije o servisera
telefon = models.CharField(max_length=20, blank=True)
oib = models.CharField(max_length=11, blank=True, null=True)
occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence")
is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil")
# Poveznica na klijenta (ako je korisnik kupac)
client_profile = models.ForeignKey(
'crm.Client',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='users', # Omogućuje pristup: klijent.users.all()
verbose_name="Povezani klijent"
)
objects = CustomUserManager()
# Govorimo Djangu da koristi email umjesto username-a
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['first_name', 'last_name']
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})"
def clean(self):
# if self.email == "bot@erp.hr":
# return # Preskoči validaciju za bota
super().clean()
if self.is_kupac and not self.client_profile:
raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."})
def save(self, *args, **kwargs):
self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
super().save(*args, **kwargs)

View File

@@ -0,0 +1,73 @@
from rest_framework import serializers
from django.core.exceptions import ObjectDoesNotExist
from .models import CustomUser
class UserSerializer(serializers.ModelSerializer):
assigned_vehicle = serializers.SerializerMethodField()
assigned_crane = serializers.SerializerMethodField()
class Meta:
model = CustomUser
fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser', 'is_kupac', 'is_team_member', 'telefon', 'oib', 'occupation', 'residence', 'work_position', 'client_profile', 'assigned_vehicle', 'assigned_crane']
read_only_fields = ['id', 'email']
extra_kwargs = {
'first_name': {'required': True, 'allow_blank': False}
}
def _get_assigned_asset(self, obj):
try:
return obj.assigned_vehicles
except ObjectDoesNotExist:
return None
def _serialize_asset(self, vehicle):
if not vehicle:
return None
return {
'id': vehicle.id,
'asset_type': vehicle.asset_type,
'registration_number': vehicle.registration_number,
'make': vehicle.make,
'model': vehicle.model,
'current_mileage': vehicle.current_mileage,
'service_interval_km': vehicle.service_interval_km,
}
def get_assigned_vehicle(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
def get_assigned_crane(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
def validate(self, data):
"""
Provodi poslovnu logiku na razini serializer-a.
"""
is_kupac = data.get('is_kupac')
client_profile = data.get('client_profile')
# Logika: Ako je kupac, mora imati profil
if is_kupac and not client_profile:
raise serializers.ValidationError({
"client_profile": "Korisnik označen kao kupac mora imati dodijeljen klijentski profil."
})
# Logika: Ako ima profil, trebao bi biti kupac (opcionalno, ali preporučeno)
if client_profile and not is_kupac:
raise serializers.ValidationError({
"is_kupac": "Korisnik s dodijeljenim klijentskim profilom mora biti označen kao kupac."
})
return data
class TeamMemberSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
class Meta:
model = CustomUser
fields = ['id', 'email', 'full_name', 'is_team_member']
read_only_fields = fields
def get_full_name(self, obj):
return obj.get_full_name() or obj.email

View File

@@ -0,0 +1,35 @@
# backend/core/users/services.py
import logging
from django.contrib.auth import get_user_model
from rest_framework.exceptions import ValidationError
from .serializers import UserSerializer
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import CustomUser as User
else:
User = get_user_model()
logger = logging.getLogger(__name__)
class UserService:
@staticmethod
def update_user_profile(user, data: dict) -> User:
"""
Ažurira profil koristeći serializer za validaciju.
Ovo osigurava da poslovna pravila definirana u serializeru
budu poštivana i kod programskog ažuriranja.
"""
# Koristimo serializer za validaciju dolaznih podataka
serializer = UserSerializer(user, data=data, partial=True)
if not serializer.is_valid():
logger.error(f"Neuspješna validacija profila korisnika {user.id}: {serializer.errors}")
raise ValidationError(serializer.errors)
# Spremanje validiranih podataka
updated_user = serializer.save()
logger.info(f"Korisnik {user.id} je uspješno ažurirao profil.")
return updated_user

View File

@@ -0,0 +1,25 @@
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
def test_create_user():
"""Testira kreiranje standardnog korisnika."""
user = User.objects.create_user(
email="test@example.com",
password="password123"
)
assert user.email == "test@example.com"
assert user.check_password("password123")
assert user.is_active is True
@pytest.mark.django_db
def test_create_superuser():
"""Testira kreiranje superuser-a."""
admin = User.objects.create_superuser(
email="admin@example.com",
password="password123"
)
assert admin.is_superuser is True
assert admin.is_staff is True

View File

@@ -0,0 +1,37 @@
# backend/core/users/tests/test_permissions.py
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestUserPermissions:
def setup_method(self):
self.client = APIClient()
# Kreiramo dva korisnika
self.user1 = User.objects.create_user(email="user1@erp.hr", password="password123")
self.user2 = User.objects.create_user(email="user2@erp.hr", password="password123")
def test_user_cannot_edit_other_user(self):
"""Osigurava da user1 ne može mijenjati podatke od user2."""
self.client.force_authenticate(user=self.user1)
# Pokušavamo patchati user2 (pretpostavljamo URL /api/users/{id}/)
response = self.client.patch(f'/api/users/{self.user2.id}/', {"first_name": "Haker"})
# Trebali bismo dobiti 403 Forbidden ili 404 (ovisno kako skrivaš postojanje drugih)
assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
def test_authenticated_user_can_edit_self(self):
"""Osigurava da user1 može mijenjati svoje podatke."""
self.client.force_authenticate(user=self.user1)
response = self.client.patch(f'/api/users/{self.user1.id}/', {"first_name": "Ime"})
assert response.status_code == status.HTTP_200_OK
self.user1.refresh_from_db()
assert self.user1.first_name == "Ime"

View File

@@ -0,0 +1,56 @@
import uuid
import pytest
from django.contrib.auth import get_user_model
from core.management.commands.seed_all_models import Command
from modules.fleet.models import Vehicle, VehicleServiceRecord
from modules.task_management.models import Task
@pytest.mark.django_db
def test_seed_service_records_creates_record_with_task_and_task_vehicle():
user = get_user_model().objects.create_user(
email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test",
password="pass12345",
)
vehicle = Vehicle.objects.create(
registration_number=f"ST-{uuid.uuid4().hex[:6].upper()}",
asset_type="crane",
)
task = Task.objects.create(
title=f"Task {uuid.uuid4().hex[:6]}",
status="aktivan",
vehicle=vehicle,
assigned_to=user,
)
cmd = Command()
before = VehicleServiceRecord.objects.count()
cmd._seed_service_records(1, [user], [task])
assert VehicleServiceRecord.objects.count() == before + 1
record = VehicleServiceRecord.objects.order_by("-created_at").first()
assert record is not None
assert record.task_id == task.id
assert record.vehicle_id == task.vehicle_id
@pytest.mark.django_db
def test_seed_service_records_skips_tasks_without_vehicle():
user = get_user_model().objects.create_user(
email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test",
password="pass12345",
)
task = Task.objects.create(
title=f"Task {uuid.uuid4().hex[:6]}",
status="aktivan",
vehicle=None,
assigned_to=user,
)
cmd = Command()
before = VehicleServiceRecord.objects.count()
cmd._seed_service_records(1, [user], [task])
assert VehicleServiceRecord.objects.count() == before

View File

@@ -0,0 +1,35 @@
import pytest
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.exceptions import ValidationError as DRFValidationError
from core.users.services import UserService
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestUserService:
def setup_method(self):
self.user = User.objects.create_user(
email="servis@primjer.hr",
password="password123"
)
def test_update_profile_success(self):
"""Testira uspješno ažuriranje profila preko servisa."""
new_data = {"first_name": "Novi", "last_name": "Korisnik"}
updated_user = UserService.update_user_profile(self.user, new_data)
assert updated_user.first_name == "Novi"
assert updated_user.last_name == "Korisnik"
# Provjera da email nije promijenjen ako nismo poslali
assert updated_user.email == "servis@primjer.hr"
def test_update_profile_invalid_data(self):
"""Testira validaciju (npr. neispravan format podataka)."""
# Pretpostavimo da dodamo logiku u serializer koja brani prazno ime
invalid_data = {"first_name": ""}
with pytest.raises(DRFValidationError):
UserService.update_user_profile(self.user, invalid_data)

View File

@@ -0,0 +1,61 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestUserAPI:
def setup_method(self):
self.client = APIClient()
self.user_data = {
"email": "test@primjer.hr",
"first_name": "Test",
"last_name": "Korisnik",
"password": "strongpassword123"
}
self.user = User.objects.create_user(**self.user_data)
def test_get_me_authenticated(self):
"""Testira dohvat vlastitih podataka (uspjeh)."""
self.client.force_authenticate(user=self.user)
url = reverse('user-me') # Provjeri naziv u routeru
response = self.client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['email'] == self.user.email
assert 'is_serviser' in response.data
def test_get_me_unauthenticated(self):
"""Testira pristup bez prijave (neuspjeh)."""
url = reverse('user-me')
response = self.client.get(url)
# Ovisno o tvojim permission_classes, ovdje bi trebalo biti 401 ili 403
assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
def test_user_creation(self):
"""Testira osnovno stvaranje korisnika u bazi."""
assert User.objects.count() == 1
assert self.user.is_serviser is False
def test_update_user_via_api(self):
"""Testira cjelokupan tok: API -> ViewSet -> Service."""
self.client.force_authenticate(user=self.user)
url = reverse('user-detail', kwargs={'pk': self.user.id}) # Provjeri basename
new_data = {"first_name": "Inženjer"}
# Simuliramo PATCH zahtjev
response = self.client.patch(url, new_data)
# Osvježi instancu iz baze
self.user.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert self.user.first_name == "Inženjer"

View File

@@ -0,0 +1,13 @@
# backend/core/users/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import UserViewSet
router = DefaultRouter()
# Registriramo UserViewSet pod imenom 'users'
# Ovo će generirati rute poput /api/users/me/
router.register(r'', UserViewSet, basename='user')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,44 @@
# backend/core/users/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from django.contrib.auth import get_user_model
from .serializers import TeamMemberSerializer, UserSerializer
from .services import UserService
User = get_user_model()
class IsOwner(permissions.BasePermission):
"""Prilagođeni permission: Korisnik može uređivati samo sebe."""
def has_object_permission(self, request, view, obj):
return obj.id == request.user.id
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
@action(detail=False, methods=['get'])
def me(self, request):
serializer = self.get_serializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='team-members')
def team_members(self, request):
queryset = User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data)
def perform_update(self, serializer):
# Delegiramo logiku servisu
UserService.update_user_profile(
user=self.request.user,
data=serializer.validated_data
)
def get_queryset(self):
# Korisnik vidi samo sebe (ili admini vide sve)
if self.request.user.is_staff:
return User.objects.all()
return User.objects.filter(id=self.request.user.id)

20
backend/core/utils.py Normal file
View File

@@ -0,0 +1,20 @@
# backend/core/utils.py
from datetime import datetime
from decimal import Decimal, InvalidOperation
def format_currency(value: str) -> Decimal:
"""Standardizirano pretvaranje valuta iz stringa u Decimal."""
try:
# Čišćenje stringa (npr. uklanjanje 'kn' ili '.' separatora)
clean_value = value.replace(',', '.').replace(' ', '')
return Decimal(clean_value)
except (InvalidOperation, ValueError):
return Decimal('0.00')
def parse_iso_date(date_str: str):
"""Standardizirano parsiranje datuma za cijeli sustav."""
try:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except (ValueError, TypeError):
return None

View File

16
backend/core/wsgi.py Normal file
View File

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

101
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,101 @@
#!/bin/sh
set -e
export PATH="/usr/local/bin:$PATH"
APP_DIR="/app"
APP_USER="appuser"
APP_UID=1000
STATIC_ROOT="${STATIC_ROOT:-/app/staticfiles}"
MEDIA_ROOT="${MEDIA_ROOT:-/app/media}"
# Kreiraj korisnika 'appuser' s UID 1000 ako ne postoji
if ! getent passwd "${APP_USER}" > /dev/null; then
echo "--> Kreiram ${APP_USER}..."
addgroup --system --gid ${APP_UID} ${APP_USER} || true
adduser --system --uid ${APP_UID} --ingroup ${APP_USER} --no-create-home --disabled-login ${APP_USER} || true
fi
# Ako smo root, pokušaj osigurati ownership. Ako nismo root, samo logiramo i nastavljamo.
if [ "$(id -u)" -eq 0 ]; then
echo "--> (root) Kreiram/ajustiram direktorije i dozvole..."
mkdir -p "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"
# pokušaj chown, ali ne izlazi na grešku ako neuspije (možda host FS to ne dozvoljava)
if chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"; then
echo "--> Chown uspješan."
else
echo "--> Upozorenje: chown nije uspio (moguće root_squash ili mount bez chown dopuštenja)."
fi
else
echo "--> Nismo root (uid=$(id -u)), preskačem chown. Provjeri vlasništvo na hostu ako su problemi s permisijama."
fi
# Helper: run command as appuser but return to the script (do not replace shell)
run_as_appuser_now() {
if command -v gosu >/dev/null 2>&1; then
gosu ${APP_USER} "$@"
elif command -v su-exec >/dev/null 2>&1; then
su-exec ${APP_USER} "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u ${APP_USER} -- "$@"
else
# If we're already non-root (appuser), run directly; if root and no helpers, run directly anyway (with warning)
if [ "$(id -u)" -ne 0 ]; then
"$@"
else
echo "--> Upozorenje: nema gosu/su-exec/runuser; izvršavam kao root (nepoželjno)."
"$@"
fi
fi
}
# Helper: exec the given command as appuser (replace current shell / PID 1)
exec_as_appuser() {
# Ako smo već appuser, samo izvrši naredbu direktno
if [ "$(id -u -n)" = "${APP_USER}" ]; then
exec "$@"
# Inače, pokušaj koristiti alate za switch
elif command -v gosu >/dev/null 2>&1; then
exec gosu ${APP_USER} "$@"
elif command -v su-exec >/dev/null 2>&1; then
exec su-exec ${APP_USER} "$@"
else
# Fallback
exec "$@"
fi
}
if [ "$1" = "gunicorn" ]; then
echo "--> Pokrećem migracije..."
# Ako smo root, pokrenimo migrate kao appuser; ako smo non-root (već appuser), pokreni direktno
if [ "$(id -u)" -eq 0 ]; then
run_as_appuser_now python manage.py migrate --noinput
else
python manage.py migrate --noinput
fi
echo "--> Osiguravam prava nad statikom prije collectstatic..."
mkdir -p "${STATIC_ROOT}"
if [ "$(id -u)" -eq 0 ]; then
chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio"
fi
echo "--> Skupljam statiku..."
if command -v gosu >/dev/null 2>&1 || command -v su-exec >/dev/null 2>&1 || command -v runuser >/dev/null 2>&1; then
run_as_appuser_now python manage.py collectstatic --noinput
else
python manage.py collectstatic --noinput
fi
echo "--> Pokrećem Gunicorn kao appuser..."
exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
elif [ "$1" = "celery" ]; then
echo "--> Pokrećem Celery kao appuser..."
shift
# Replace PID1 with celery running as appuser
exec_as_appuser celery "$@"
else
exec "$@"
fi

View File

View File

@@ -0,0 +1,43 @@
# backend/infrastructure/paperless_client.py
import requests
from django.conf import settings
class PaperlessGateway:
"""
Gateway za komunikaciju s Paperless-ngx API-jem.
"""
def __init__(self):
self.base_url = settings.PAPERLESS_API_URL
self.headers = {
"Authorization": f"Token {settings.PAPERLESS_API_TOKEN}",
"Content-Type": "application/json"
}
def upload_document(self, file_content, filename):
"""Šalje dokument u Paperless."""
url = f"{self.base_url}/documents/post_document/"
files = {'document': (filename, file_content)}
response = requests.post(url, headers={"Authorization": self.headers["Authorization"]}, files=files)
response.raise_for_status()
return response.json()
def get_document_status(self, task_id):
"""Provjerava status procesiranja dokumenta."""
url = f"{self.base_url}/tasks/{task_id}/"
response = requests.get(url, headers=self.headers)
return response.json()
def get_document_metadata(self, document_id: int):
"""Dohvaća metapodatke dokumenta iz Paperless-a."""
url = f"{self.base_url}/api/documents/{document_id}/"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
def fetch_unprocessed_documents(self):
"""Dohvaća listu dokumenata koji čekaju obradu."""
# Filtriramo dokumente koji nemaju naš 'processed' tag
url = f"{self.base_url}/api/documents/?query=is_tagged:false"
response = requests.get(url, headers=self.headers)
return response.json().get('results', [])

View File

@@ -0,0 +1,221 @@
# backend/modules/fleet/services/pusher_service.py
"""
Pusher Integration Service for Real-time Notifications
Handles broadcasting notifications to users via WebSocket channels
"""
import logging
import json
from typing import Optional, Dict, Any
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
try:
import pusher
PUSHER_AVAILABLE = True
except ImportError:
PUSHER_AVAILABLE = False
pusher = None
logger = logging.getLogger(__name__)
class PusherService:
"""
Wrapper service for Pusher integration.
Handles event broadcasting, channel authentication, and error handling.
"""
_instance = None
_client = None
def __new__(cls):
"""Singleton pattern to reuse Pusher client"""
if cls._instance is None:
cls._instance = super(PusherService, cls).__new__(cls)
return cls._instance
@classmethod
def get_client(cls):
"""Get or initialize Pusher client"""
if cls._client is None:
if not PUSHER_AVAILABLE:
logger.warning("Pusher library not installed. Install with: pip install pusher")
return None
try:
pusher_config = {
'app_id': settings.PUSHER_APP_ID,
'key': settings.PUSHER_KEY,
'secret': settings.PUSHER_SECRET,
'cluster': settings.PUSHER_CLUSTER,
'ssl': True,
'json_encoder': DjangoJSONEncoder,
}
cls._client = pusher.Pusher(**pusher_config)
except AttributeError as e:
logger.error(f"Pusher configuration missing: {e}")
return None
return cls._client
@staticmethod
def is_available() -> bool:
"""Check if Pusher is available and configured"""
if not PUSHER_AVAILABLE:
return False
required_values = [
settings.PUSHER_APP_ID,
settings.PUSHER_KEY,
settings.PUSHER_SECRET,
settings.PUSHER_CLUSTER,
]
return all(required_values)
@staticmethod
def get_private_channel_name(user_id: int) -> str:
"""Generate private channel name for user"""
return f"private-user-{user_id}"
@staticmethod
def get_presence_channel_name() -> str:
"""Get the presence channel name for fleet activity"""
return "presence-fleet"
@classmethod
def broadcast_notification(
cls,
user_id: int,
title: str,
message: str,
level: str = 'info',
notification_id: Optional[Any] = None,
metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""
Broadcast a notification to a specific user via private channel.
Args:
user_id: ID of the user to notify
title: Notification title
message: Notification message
level: Notification level (info, warning, critical)
notification_id: ID of the VehicleNotification record
metadata: Additional metadata to send with notification
Returns:
True if broadcast successful, False otherwise
"""
if not cls.is_available():
logger.debug("Pusher not available, skipping broadcast")
return False
client = cls.get_client()
if not client:
return False
channel_name = cls.get_private_channel_name(user_id)
event_name = "vehicle.notification.created"
from django.utils.timezone import now
event_data = {
'id': cls._to_json_safe(notification_id),
'title': title,
'message': message,
'level': level,
'is_read': False,
'created_at': now().isoformat(),
}
if metadata:
event_data['metadata'] = cls._to_json_safe(metadata)
try:
response = client.trigger(
channels=channel_name,
event_name=event_name,
data=event_data
)
logger.info(f"Notification broadcasted to {channel_name}: {event_name}")
return True
except Exception as e:
logger.exception(f"Error broadcasting notification to {channel_name}: {e}")
return False
@staticmethod
def _to_json_safe(value: Any) -> Any:
"""
Convert complex Python/Django values (UUID, date, Decimal, etc.) into
JSON-serializable primitives using DjangoJSONEncoder.
"""
return json.loads(json.dumps(value, cls=DjangoJSONEncoder))
@classmethod
def broadcast_to_multiple_users(
cls,
user_ids: list,
title: str,
message: str,
level: str = 'info',
notification_ids: Optional[Dict[int, int]] = None
) -> Dict[int, bool]:
"""
Broadcast the same notification to multiple users.
Args:
user_ids: List of user IDs to notify
title: Notification title
message: Notification message
level: Notification level
notification_ids: Dict mapping user_id to notification_id
Returns:
Dict of user_id -> success boolean
"""
results = {}
for user_id in user_ids:
notification_id = notification_ids.get(user_id) if notification_ids else None
results[user_id] = cls.broadcast_notification(
user_id=user_id,
title=title,
message=message,
level=level,
notification_id=notification_id
)
return results
@classmethod
def authenticate_channel(cls, socket_id: str, channel_name: str, user_id: int) -> Optional[Dict[str, Any]]:
"""
Authenticate a user for a private or presence channel.
Args:
socket_id: Pusher socket ID from client
channel_name: Channel name to authenticate
user_id: Current user ID
Returns:
Signed authentication data or None if authentication fails
"""
if not cls.is_available():
logger.debug("Pusher not available for authentication")
return None
client = cls.get_client()
if not client:
return None
# Only allow subscription to user's own private channel
expected_channel = cls.get_private_channel_name(user_id)
if channel_name != expected_channel and channel_name != cls.get_presence_channel_name():
logger.warning(f"Unauthorized channel subscription attempt: {channel_name} by user {user_id}")
return None
try:
auth_data = client.authenticate(channel=channel_name, socket_id=socket_id)
logger.info(f"Channel {channel_name} authenticated for user {user_id}")
return auth_data
except Exception as e:
logger.exception(f"Error authenticating channel {channel_name}: {e}")
return None

View File

22
backend/manage.py Normal file
View File

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

View File

View File

@@ -0,0 +1,86 @@
from django import forms
from django.contrib import admin
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from modules.fleet.models import Crane
from .models import Client
class ClientAdminForm(forms.ModelForm):
customer_cranes = forms.ModelMultipleChoiceField(
label=_("Vozila kupca"),
queryset=Crane.objects.filter(is_active=True, asset_type='crane').order_by('registration_number'),
required=False,
widget=FilteredSelectMultiple(_("Dizalice"), is_stacked=False),
help_text=_("Odaberite postojeće dizalice koje pripadaju kupcu."),
)
class Meta:
model = Client
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['customer_cranes'].initial = Crane.objects.filter(client=self.instance, is_active=True, asset_type='crane')
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
form = ClientAdminForm
# Polja koja se prikazuju u listi
list_display = ('name', 'tax_id', 'city', 'client_type', 'vehicle_count', 'is_active', 'created_at')
# Polja koja omogućuju klik na objekt
list_display_links = ('name', 'tax_id')
# Filteri sa strane (vrlo korisno za ERP)
list_filter = ('client_type', 'is_active', 'created_at')
# Polja po kojima admin može pretraživati
search_fields = ('name', 'tax_id', 'email')
readonly_fields = ('created_at', 'updated_at', 'linked_cranes_preview')
# Grupiranje polja u formi za ljepši pregled
fieldsets = (
(_('Osnovni podaci'), {
'fields': ('name', 'client_type', 'tax_id')
}),
(_('Kontakt podaci'), {
'fields': ('email', 'phone', 'address', 'city', 'postal_code')
}),
(_('Vozila kupca'), {
'fields': ('customer_cranes', 'linked_cranes_preview')
}),
(_('Sustavni podaci'), {
'fields': ('is_active', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
list_per_page = 50
@admin.display(description="Dizalice")
def vehicle_count(self, obj):
return obj.vehicles.filter(asset_type='crane').count()
@admin.display(description="Povezane dizalice")
def linked_cranes_preview(self, obj):
if not obj.pk:
return _("Spremite klijenta za prikaz povezanih dizalica.")
cranes = Crane.objects.filter(client=obj, is_active=True, asset_type='crane').order_by('registration_number')
if not cranes.exists():
return _("Nema povezanih dizalica.")
return ", ".join(str(crane) for crane in cranes)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
selected_cranes = form.cleaned_data.get('customer_cranes')
if selected_cranes is None:
return
selected_ids = list(selected_cranes.values_list('id', flat=True))
Crane.objects.filter(client=form.instance, asset_type='crane').exclude(pk__in=selected_ids).update(client=None)
if selected_ids:
Crane.objects.filter(pk__in=selected_ids).update(client=form.instance, is_company_vehicle=False, asset_type='crane')

View File

@@ -0,0 +1,8 @@
# /backend/modules/crm/apps.py
from django.apps import AppConfig
class FleetConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'modules.crm'
verbose_name = "Customer Relationship Management"

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.15 on 2026-06-29 20:13
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Client',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('name', models.CharField(max_length=255, verbose_name='Naziv/Ime')),
('client_type', models.CharField(choices=[('legal', 'Pravna osoba'), ('individual', 'Fizička osoba')], default='legal', max_length=20)),
('tax_id', models.CharField(max_length=50, unique=True, verbose_name='OIB/VAT broj')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')),
('address', models.TextField(verbose_name='Adresa')),
('city', models.CharField(max_length=100, verbose_name='Grad')),
('postal_code', models.CharField(max_length=20, verbose_name='Poštanski broj')),
],
options={
'verbose_name': 'Klijent',
'verbose_name_plural': 'Klijenti',
'ordering': ['name'],
},
),
]

View File

@@ -0,0 +1,31 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.base_models import BaseModel
class Client(BaseModel):
# Tip klijenta: OIB/VAT broj je ključan za fakturiranje
CLIENT_TYPES = [
('legal', _('Pravna osoba')),
('individual', _('Fizička osoba')),
]
name = models.CharField(max_length=255, verbose_name=_("Naziv/Ime"))
client_type = models.CharField(max_length=20, choices=CLIENT_TYPES, default='legal')
# Porezni podaci (za fakturiranje)
tax_id = models.CharField(max_length=50, unique=True, verbose_name=_("OIB/VAT broj"))
# Kontakt podaci
email = models.EmailField(unique=True, verbose_name=_("Email"))
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Telefon"))
address = models.TextField(verbose_name=_("Adresa"))
city = models.CharField(max_length=100, verbose_name=_("Grad"))
postal_code = models.CharField(max_length=20, verbose_name=_("Poštanski broj"))
class Meta:
verbose_name = _("Klijent")
verbose_name_plural = _("Klijenti")
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.tax_id})"

View File

@@ -0,0 +1,50 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DRFValidationError
from .models import Client
from .services import ClientService
class ClientSerializer(serializers.ModelSerializer):
class Meta:
model = Client
fields = [
'id', 'name', 'client_type', 'tax_id',
'email', 'phone', 'address', 'city', 'postal_code',
'created_at', 'is_active'
]
read_only_fields = ['id', 'created_at', 'is_active']
def _get_request_user(self):
"""
Sigurno dohvaćanje korisnika iz contexta serializer-a.
Request iz RequestFactory može biti WSGIRequest bez atributa 'user',
zato koristimo getattr.
"""
request = self.context.get('request') if self.context else None
return getattr(request, 'user', None)
def create(self, validated_data):
"""
Delegate creation to service layer.
"""
try:
user = self._get_request_user()
return ClientService.create_client(validated_data, user=user)
except DRFValidationError:
# re-raise as-is
raise
except Exception as exc:
# wrap non-DRF exceptions
raise DRFValidationError({"detail": str(exc)})
def update(self, instance, validated_data):
"""
Delegate update to service layer.
"""
try:
user = self._get_request_user()
return ClientService.update_client(instance, validated_data, user=user)
except DRFValidationError:
raise
except Exception as exc:
raise DRFValidationError({"detail": str(exc)})

View File

@@ -0,0 +1,65 @@
from django.db import transaction, IntegrityError
from rest_framework.exceptions import ValidationError
from .models import Client
import logging
logger = logging.getLogger(__name__)
class ClientService:
@staticmethod
def create_client(data: dict, user=None) -> Client:
"""
Create a Client instance with model validation and handle IntegrityError.
"""
# Instantiate model and run model-level validation
client = Client(**data)
try:
client.full_clean()
except Exception as e:
logger.error(f"Client validation failed: {e}")
raise ValidationError({"detail": str(e)})
try:
with transaction.atomic():
client.save()
except IntegrityError as exc:
logger.error(f"Integrity error creating client: {exc}")
# Convert DB integrity errors into DRF ValidationError for the API layer
raise ValidationError({"detail": str(exc)})
return client
@staticmethod
def update_client(instance: Client, data: dict, user=None) -> Client:
"""
Update the given Client instance. Validate and save.
"""
for attr, value in data.items():
setattr(instance, attr, value)
try:
instance.full_clean()
except Exception as e:
logger.error(f"Client validation failed on update: {e}")
raise ValidationError({"detail": str(e)})
try:
with transaction.atomic():
instance.save()
except IntegrityError as exc:
logger.error(f"Integrity error updating client: {exc}")
raise ValidationError({"detail": str(exc)})
return instance
@staticmethod
def delete_client(instance: Client, user=None) -> Client:
"""
Soft-delete the client (if you want soft delete).
If hard delete is desired, call instance.delete() instead.
"""
instance.is_active = False
instance.save()
return instance

View File

@@ -0,0 +1,75 @@
import uuid
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from modules.crm.models import Client
class ClientModelTests(TestCase):
def setUp(self):
# osnovni validni podaci koje ćemo koristiti u testovima
self.base_data = {
"name": "Test Client",
"tax_id": str(uuid.uuid4()), # jedinstveno
"email": f"client+{uuid.uuid4()}@example.test",
"address": "Ulica 1",
"city": "Grad",
"postal_code": "10000",
}
def test_create_client_success(self):
client = Client.objects.create(
client_type="legal",
**self.base_data
)
self.assertIsNotNone(client.pk)
self.assertEqual(client.name, "Test Client")
self.assertEqual(client.client_type, "legal")
# default ordering ili drugi atributi mogu se dodatno provjeriti
self.assertTrue(str(client).startswith("Test Client"))
self.assertIn(client.tax_id, str(client))
def test_default_client_type_is_legal(self):
# Ako eksplicitno ne pošaljemo client_type, default je 'legal'
data = dict(self.base_data)
data["tax_id"] = str(uuid.uuid4())
data["email"] = f"client+{uuid.uuid4()}@example.test"
client = Client.objects.create(**data)
self.assertEqual(client.client_type, "legal")
def test_str_representation_contains_name_and_taxid(self):
client = Client.objects.create(client_type="individual", **self.base_data)
expected = f"{client.name} ({client.tax_id})"
self.assertEqual(str(client), expected)
def test_unique_tax_id_constraint(self):
# Prva kreacija prođe
Client.objects.create(client_type="legal", **self.base_data)
# Pokušaj dupliciranja tax_id-a treba podići IntegrityError
dup = dict(self.base_data)
dup["email"] = f"client+{uuid.uuid4()}@example.test" # unique email drugi
with self.assertRaises(IntegrityError):
Client.objects.create(client_type="legal", **dup)
def test_unique_email_constraint(self):
Client.objects.create(client_type="legal", **self.base_data)
dup = dict(self.base_data)
dup["tax_id"] = str(uuid.uuid4()) # unique tax_id drugi
with self.assertRaises(IntegrityError):
Client.objects.create(client_type="legal", **dup)
def test_full_clean_validates_email_format(self):
data = dict(self.base_data)
data["email"] = "not-an-email"
client = Client(**data)
with self.assertRaises(ValidationError):
client.full_clean() # EmailField validation je pokrenuta
def test_full_clean_requires_required_fields(self):
# Uklonimo obavezno polje name i očekujemo ValidationError
data = dict(self.base_data)
data.pop("name")
client = Client(**data)
with self.assertRaises(ValidationError):
client.full_clean()

View File

@@ -0,0 +1,43 @@
import uuid
from django.test import TestCase, RequestFactory
from unittest.mock import patch
from modules.crm.serializers import ClientSerializer
from modules.crm.models import Client
class ClientSerializerTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.base_data = {
"name": "Ser Client",
"client_type": "legal",
"tax_id": str(uuid.uuid4()),
"email": f"client+{uuid.uuid4()}@example.test",
"address": "Street 1",
"city": "City",
"postal_code": "00000",
}
@patch("modules.crm.serializers.ClientService.create_client")
def test_create_calls_service(self, mock_create):
# Prepare mock return value
mock_instance = Client(**self.base_data)
mock_instance.pk = 1
mock_create.return_value = mock_instance
serializer = ClientSerializer(data=self.base_data, context={"request": self.factory.post("/")})
self.assertTrue(serializer.is_valid(), serializer.errors)
result = serializer.save()
mock_create.assert_called_once()
self.assertEqual(result.pk, 1)
@patch("modules.crm.serializers.ClientService.update_client")
def test_update_calls_service(self, mock_update):
client = Client.objects.create(**self.base_data)
mock_update.return_value = client
serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.factory.put("/")})
self.assertTrue(serializer.is_valid(), serializer.errors)
result = serializer.save()
mock_update.assert_called_once()
self.assertEqual(result, client)

View File

@@ -0,0 +1,36 @@
import uuid
from django.test import TestCase
from rest_framework.exceptions import ValidationError
from modules.crm.services import ClientService
from modules.crm.models import Client
class ClientServiceTests(TestCase):
def setUp(self):
self.base_data = {
"name": "Service Client",
"client_type": "legal",
"tax_id": str(uuid.uuid4()),
"email": f"client+{uuid.uuid4()}@example.test",
"address": "Street 1",
"city": "City",
"postal_code": "00000",
}
def test_create_client_success(self):
client = ClientService.create_client(self.base_data)
self.assertIsNotNone(client.pk)
self.assertEqual(client.name, "Service Client")
def test_create_client_duplicate_tax_id_raises(self):
# create first
Client.objects.create(**self.base_data)
dup = dict(self.base_data)
dup["email"] = f"client+{uuid.uuid4()}@example.test"
with self.assertRaises(ValidationError):
ClientService.create_client(dup)
def test_update_client_success(self):
client = Client.objects.create(**self.base_data)
updated = ClientService.update_client(client, {"city": "NewCity"})
self.assertEqual(updated.city, "NewCity")

View File

@@ -0,0 +1,62 @@
import uuid
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from modules.crm.serializers import ClientSerializer
from modules.crm.views import ClientViewSet
from modules.crm.models import Client
class ClientViewSetTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass")
self.factory = RequestFactory()
self.view = ClientViewSet()
self.view.request = self.factory.post("/")
self.view.request.user = self.user
self.base_data = {
"name": "View Client",
"client_type": "legal",
"tax_id": str(uuid.uuid4()),
"email": f"client+{uuid.uuid4()}@example.test",
"address": "Street 1",
"city": "City",
"postal_code": "00000",
}
@patch("modules.crm.views.ClientService.create_client")
def test_perform_create_delegates_to_service(self, mock_create):
# make serializer valid
serializer = ClientSerializer(data=self.base_data, context={"request": self.view.request})
self.assertTrue(serializer.is_valid(), serializer.errors)
mock_inst = Client(**self.base_data)
mock_inst.pk = 1
mock_create.return_value = mock_inst
self.view.request = self.factory.post("/")
self.view.request.user = self.user
# perform_create should call serializer.save() which delegates to service
self.view.perform_create(serializer)
mock_create.assert_called_once()
@patch("modules.crm.views.ClientService.update_client")
def test_perform_update_delegates_to_service(self, mock_update):
client = Client.objects.create(**self.base_data)
serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.view.request})
self.assertTrue(serializer.is_valid(), serializer.errors)
mock_update.return_value = client
self.view.request = self.factory.put("/")
self.view.request.user = self.user
serializer.instance = client
self.view.perform_update(serializer)
mock_update.assert_called_once()
client.refresh_from_db()
# serializer.perform_update causes serializer.save() which we delegated; check instance still exists
self.assertIsNotNone(client.pk)

View File

@@ -0,0 +1,7 @@
from rest_framework.routers import DefaultRouter
from .views import ClientViewSet
router = DefaultRouter()
router.register(r'clients', ClientViewSet, basename='client')
urlpatterns = router.urls

View File

@@ -0,0 +1,41 @@
import logging
from rest_framework import viewsets, permissions
from rest_framework.exceptions import ValidationError
from .models import Client
from .serializers import ClientSerializer
from .services import ClientService
logger = logging.getLogger(__name__)
class ClientViewSet(viewsets.ModelViewSet):
queryset = Client.objects.filter(is_active=True)
serializer_class = ClientSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
try:
instance = serializer.save()
serializer.instance = instance
except ValidationError as e:
logger.warning(f"Validation error creating client by {self.request.user}: {e}")
raise e
except Exception as e:
logger.error(f"Unexpected error creating client: {e}", exc_info=True)
raise ValidationError({"detail": "Došlo je do greške pri stvaranju klijenta."})
def perform_update(self, serializer):
try:
instance = serializer.save()
serializer.instance = instance
except ValidationError as e:
logger.warning(f"Validation error updating client by {self.request.user}: {e}")
raise e
except Exception as e:
logger.error(f"Unexpected error updating client: {e}", exc_info=True)
raise ValidationError({"detail": "Došlo je do greške pri ažuriranju klijenta."})
def perform_destroy(self, instance):
# Delegate deletion (soft-delete) to service layer
ClientService.delete_client(instance, user=self.request.user)
logger.info(f"Client {instance.id} soft-deleted by {self.request.user}")

View File

View File

@@ -0,0 +1,197 @@
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.utils.html import format_html
from .models import (
Crane,
Vehicle,
WorkOrder,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
)
class ServicePhotoInline(admin.TabularInline):
model = VehicleServicePhoto
extra = 0
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at')
readonly_fields = ('thumbnail_preview', 'created_at')
show_change_link = True
@admin.display(description="Pregled")
def thumbnail_preview(self, obj):
if obj.image:
return format_html(
'<a href="{}" target="_blank">'
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.image.url,
obj.image.url,
)
return ""
class ServiceAttachmentInline(admin.TabularInline):
model = VehicleServiceAttachment
extra = 0
fields = ('file', 'description', 'uploaded_by', 'created_at')
readonly_fields = ('created_at',)
show_change_link = True
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
class ServiceRecordInline(admin.TabularInline):
model = VehicleServiceRecord
extra = 0
fields = ('task', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost')
readonly_fields = ('service_date', 'next_service_due_at')
class WorkOrderInvoiceInline(admin.TabularInline):
model = WorkOrderInvoice
extra = 0
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
readonly_fields = ('created_at',)
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = (
'registration_number',
'crane_serial_number',
'superstructure_working_hours',
'chassis_working_hours',
'make',
'model',
'year',
'current_mileage',
'service_interval_km',
'next_service_at_display',
'is_company_vehicle',
'client',
'assigned_servicer',
'is_active',
)
list_filter = ('is_active', 'is_company_vehicle', 'make', 'service_interval_km')
search_fields = ('registration_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
readonly_fields = ()
inlines = (WorkOrderInline, ServiceRecordInline)
ordering = ('registration_number',)
fieldsets = (
(None, {
'fields': ('asset_type', 'registration_number', 'vin', 'make', 'model', 'year'),
}),
('Vlasništvo', {
'fields': ('is_company_vehicle', 'client', 'assigned_servicer'),
}),
('Servis', {
'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
}),
)
def next_service_at_display(self, obj):
try:
return obj.next_service_at()
except Exception:
return None
next_service_at_display.short_description = "Sljedeći servis @ (km)"
next_service_at_display.admin_order_field = 'current_mileage'
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.filter(asset_type='vehicle')
def save_model(self, request, obj, form, change):
obj.asset_type = 'vehicle'
super().save_model(request, obj, form, change)
@admin.register(Crane)
class CraneAdmin(VehicleAdmin):
list_display = (
'registration_number',
'crane_serial_number',
'superstructure_working_hours',
'chassis_working_hours',
'make',
'model',
'year',
'current_mileage',
'service_interval_km',
'next_service_at_display',
'client',
'assigned_servicer',
'is_active',
)
list_filter = ('is_active', 'client', 'make', 'service_interval_km', 'crane_serial_number')
search_fields = ('registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
fieldsets = (
(None, {
'fields': ('asset_type', 'registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'year'),
}),
('Povezivanje', {
'fields': ('client', 'assigned_servicer', 'is_company_vehicle'),
}),
('Servis', {
'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
}),
)
def get_queryset(self, request):
queryset = ModelAdmin.get_queryset(self, request)
return queryset.filter(asset_type='crane')
def save_model(self, request, obj, form, change):
obj.asset_type = 'crane'
ModelAdmin.save_model(self, request, obj, form, change)
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_filter = ('status', 'date', 'vehicle')
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline,)
def distance_display(self, obj):
return obj.distance
distance_display.short_description = "Prijeđeno (km)"
@admin.register(WorkOrderInvoice)
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
list_filter = ('datum', 'is_active', 'work_order')
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
ordering = ('-datum', '-created_at')
@admin.register(VehicleServiceRecord)
class VehicleServiceRecordAdmin(admin.ModelAdmin):
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
list_filter = ('service_date', 'performed_by', 'vehicle')
search_fields = ('service_title', 'vehicle__registration_number', 'performed_by__username', 'description', 'parts')
readonly_fields = ('id', 'service_date', 'next_service_due_at')
ordering = ('-service_date',)
inlines = (ServicePhotoInline, ServiceAttachmentInline)
fieldsets = (
(None, {
'fields': ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date'),
}),
('Detalji servisa', {
'fields': ('description', 'parts', 'mileage', 'cost', 'next_service_due_at'),
}),
)
@admin.display(description="Opis")
def short_description(self, obj):
if obj.description:
return obj.description[:60] + ('' if len(obj.description) > 60 else '')
return ""

View File

@@ -0,0 +1,8 @@
# /backend/modules/fleet/apps.py
from django.apps import AppConfig
class FleetConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'modules.fleet'
verbose_name = "Fleet"

View File

@@ -0,0 +1,124 @@
# Generated by Django 5.2.15 on 2026-07-01 11:39
import django.db.models.deletion
import modules.fleet.models
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Vehicle',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('registration_number', models.CharField(max_length=20, unique=True, verbose_name='Registracija')),
('make', models.CharField(blank=True, max_length=100, verbose_name='Proizvođač')),
('model', models.CharField(blank=True, max_length=100, verbose_name='Model')),
('year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Godina')),
('vin', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='VIN')),
('current_mileage', models.PositiveIntegerField(default=0, verbose_name='Trenutna kilometraža (km)')),
('service_interval_km', models.PositiveIntegerField(default=15000, verbose_name='Interval servisa (km)')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivno')),
('assigned_servicer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
],
options={
'verbose_name': 'Vozilo',
'verbose_name_plural': 'Vozila',
'ordering': ['registration_number'],
},
),
migrations.CreateModel(
name='VehicleNotification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('title', models.CharField(max_length=255, verbose_name='Naslov')),
('message', models.TextField(verbose_name='Poruka')),
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Upozorenje'), ('critical', 'Kritično')], default='info', max_length=20, verbose_name='Razina')),
('is_sent', models.BooleanField(default=False, verbose_name='Poslano (e-mail)')),
('is_read', models.BooleanField(default=False, verbose_name='Pročitano')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicle_notifications', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
],
options={
'verbose_name': 'Notifikacija za vozilo',
'verbose_name_plural': 'Notifikacije za vozila',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='VehicleServiceRecord',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('description', models.TextField(verbose_name='Opis posla')),
('parts', models.TextField(blank=True, verbose_name='Korišteni dijelovi')),
('cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Trošak')),
('service_date', models.DateField(auto_now_add=True, verbose_name='Datum servisa')),
('mileage', models.PositiveIntegerField(verbose_name='Kilometraža pri servisu')),
('next_service_due_at', models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža sljedećeg servisa')),
('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='performed_vehicle_services', to=settings.AUTH_USER_MODEL, verbose_name='Izvršio')),
('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.vehicle', verbose_name='Vozilo')),
],
options={
'verbose_name': 'Servisni zapis',
'verbose_name_plural': 'Servisni zapisi',
'ordering': ['-service_date'],
},
),
migrations.CreateModel(
name='VehicleServicePhoto',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('image', models.ImageField(upload_to=modules.fleet.models._service_photo_upload_to, verbose_name='Fotografija')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
],
options={
'verbose_name': 'Fotografija servisa',
'verbose_name_plural': 'Fotografije servisa',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='WorkOrder',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('date', models.DateField(auto_now_add=True, verbose_name='Datum')),
('start_mileage', models.PositiveIntegerField(verbose_name='Početna kilometraža')),
('end_mileage', models.PositiveIntegerField(blank=True, null=True, verbose_name='Završna kilometraža')),
('purpose', models.CharField(blank=True, max_length=255, verbose_name='Svrha')),
('notes', models.TextField(blank=True, verbose_name='Bilješke')),
('status', models.CharField(choices=[('open', 'Otvoren'), ('closed', 'Zatvoren')], default='open', max_length=20, verbose_name='Status')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_work_orders', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='work_orders', to='fleet.vehicle', verbose_name='Vozilo')),
],
options={
'verbose_name': 'Putni nalog',
'verbose_name_plural': 'Putni nalozi',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.15 on 2026-07-03 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='vehicle',
name='assigned_servicer',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.15 on 2026-07-06 08:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0001_initial'),
('fleet', '0002_alter_vehicle_assigned_servicer'),
]
operations = [
migrations.AddField(
model_name='vehicle',
name='client',
field=models.ForeignKey(blank=True, help_text='Popunite samo za vozila kupaca (is_company_vehicle = Ne).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicles', to='crm.client', verbose_name='Kupac'),
),
migrations.AddField(
model_name='vehicle',
name='is_company_vehicle',
field=models.BooleanField(default=True, help_text='Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.', verbose_name='Vozilo tvrtke'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-07-06 11:45
import django.db.models.deletion
import modules.fleet.models
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('fleet', '0003_add_is_company_vehicle_and_client_fk'),
]
operations = [
migrations.CreateModel(
name='VehicleServiceAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('file', models.FileField(upload_to=modules.fleet.models._service_attachment_upload_to, verbose_name='Datoteka')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
],
options={
'verbose_name': 'Prilog servisa',
'verbose_name_plural': 'Prilozi servisa',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-06 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0004_vehicleserviceattachment'),
]
operations = [
migrations.AddField(
model_name='vehiclenotification',
name='metadata',
field=models.JSONField(blank=True, default=dict, verbose_name='Metapodaci'),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.15 on 2026-07-06 13:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fleet', '0005_vehiclenotification_metadata'),
]
operations = [
migrations.CreateModel(
name='Crane',
fields=[],
options={
'verbose_name': 'Dizalica',
'verbose_name_plural': 'Dizalice',
'ordering': ['registration_number'],
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('fleet.vehicle',),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-06 15:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0006_crane_proxy_model'),
]
operations = [
migrations.AddField(
model_name='vehicle',
name='asset_type',
field=models.CharField(choices=[('vehicle', 'Vozilo'), ('crane', 'Dizalica')], db_index=True, default='crane', max_length=20, verbose_name='Tip sredstva'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-07-06 13:55
from django.db import migrations
def backfill_asset_type_vehicle(apps, schema_editor):
Vehicle = apps.get_model('fleet', 'Vehicle')
WorkOrder = apps.get_model('fleet', 'WorkOrder')
work_order_vehicle_ids = WorkOrder.objects.values_list('vehicle_id', flat=True)
service_vehicle_ids = (
Vehicle.objects
.filter(
assigned_servicer__isnull=False,
is_company_vehicle=True,
client__isnull=True,
)
.exclude(id__in=work_order_vehicle_ids)
.values_list('id', flat=True)
)
Vehicle.objects.filter(id__in=service_vehicle_ids).update(asset_type='vehicle')
class Migration(migrations.Migration):
dependencies = [
('fleet', '0007_vehicle_asset_type'),
]
operations = [
migrations.RunPython(backfill_asset_type_vehicle, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.15 on 2026-07-07 17:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0008_backfill_asset_type_vehicle'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='location',
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija odredišta'),
),
migrations.AddField(
model_name='workorder',
name='travel_end_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Kraj puta'),
),
migrations.AddField(
model_name='workorder',
name='travel_start_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Početak puta'),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.2.15 on 2026-07-07 17:31
import django.db.models.deletion
from django.db import migrations, models
def assign_work_order_from_vehicle(apps, schema_editor):
WorkOrder = apps.get_model('fleet', 'WorkOrder')
VehicleServiceRecord = apps.get_model('fleet', 'VehicleServiceRecord')
for record in VehicleServiceRecord.objects.filter(work_order__isnull=True).iterator():
work_order = (
WorkOrder.objects
.filter(vehicle_id=record.vehicle_id, is_active=True)
.order_by('-created_at')
.first()
)
if work_order is not None:
record.work_order_id = work_order.id
record.save(update_fields=['work_order'])
class Migration(migrations.Migration):
dependencies = [
('fleet', '0009_workorder_location_and_travel_times'),
]
operations = [
migrations.AddField(
model_name='vehicleservicerecord',
name='work_order',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.workorder', verbose_name='Radni zadatak'),
),
migrations.AlterField(
model_name='vehicleservicerecord',
name='mileage',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža pri servisu'),
),
migrations.RunPython(assign_work_order_from_vehicle, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.15 on 2026-07-07 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0010_service_record_work_order_and_optional_mileage'),
]
operations = [
migrations.AlterField(
model_name='workorder',
name='start_mileage',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Početna kilometraža'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.15 on 2026-07-07 17:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0011_workorder_start_mileage_optional'),
]
operations = [
migrations.AlterField(
model_name='workorder',
name='purpose',
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled')], max_length=50, verbose_name='Svrha'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-07-07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0002_task_work_order'),
('fleet', '0012_workorder_purpose_choices'),
]
operations = [
migrations.AddField(
model_name='vehicleservicerecord',
name='task',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='service_records',
to='task_management.task',
verbose_name='Task',
),
),
]

View File

@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0013_vehicleservicerecord_task'),
('task_management', '0003_task_vehicle'),
]
operations = [
migrations.RemoveField(
model_name='vehicleservicerecord',
name='work_order',
),
migrations.AlterField(
model_name='vehicleservicerecord',
name='task',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='service_records',
to='task_management.task',
verbose_name='Radni zadatak',
),
),
]

View File

@@ -0,0 +1,47 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
def work_order_invoice_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
class Migration(migrations.Migration):
dependencies = [
('fleet', '0014_servicerecord_remove_workorder'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WorkOrderInvoice',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('naziv_racuna', models.CharField(max_length=255, verbose_name='Naziv računa')),
('lokacija', models.CharField(blank=True, max_length=255, verbose_name='Lokacija')),
('datum', models.DateField(default=django.utils.timezone.now, verbose_name='Datum')),
('opis', models.TextField(blank=True, verbose_name='Opis')),
('image', models.ImageField(blank=True, null=True, upload_to=work_order_invoice_upload_to, verbose_name='Slika računa')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_work_order_invoices', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Račun putnog naloga',
'verbose_name_plural': 'Računi putnog naloga',
'ordering': ['-datum', '-created_at'],
},
),
]

View File

@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0015_workorderinvoice'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='servicer_vehicle_end_mileage',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - završna kilometraža'),
),
migrations.AddField(
model_name='workorder',
name='servicer_vehicle_fuel_cost',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Vozilo servisera - trošak goriva (EUR)'),
),
migrations.AddField(
model_name='workorder',
name='servicer_vehicle_make_model',
field=models.CharField(blank=True, max_length=120, verbose_name='Vozilo servisera - marka/model'),
),
migrations.AddField(
model_name='workorder',
name='servicer_vehicle_registration',
field=models.CharField(blank=True, max_length=20, verbose_name='Vozilo servisera - registracija'),
),
migrations.AddField(
model_name='workorder',
name='servicer_vehicle_start_mileage',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - početna kilometraža'),
),
]

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0016_workorder_servicer_vehicle_fields'),
]
operations = [
migrations.AddField(
model_name='vehicle',
name='crane_serial_number',
field=models.CharField(
blank=True,
db_index=True,
help_text='Serijski broj dizalice mora imati točno 6 znamenki.',
max_length=6,
null=True,
verbose_name='Serijski broj dizalice',
),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-07-09 12:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0017_vehicle_crane_serial_number'),
]
operations = [
migrations.AddField(
model_name='vehicle',
name='chassis_working_hours',
field=models.PositiveIntegerField(default=0, verbose_name='Radni sati podvozja'),
),
migrations.AddField(
model_name='vehicle',
name='superstructure_working_hours',
field=models.PositiveIntegerField(default=0, verbose_name='Radni sati nadogradnje'),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0018_vehicle_chassis_working_hours_and_more'),
]
operations = [
migrations.AddField(
model_name='vehicleservicerecord',
name='service_title',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Naziv servisnog zapisa'),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.15 on 2026-07-09 16:41
import modules.fleet.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0019_vehicleservicerecord_service_title'),
]
operations = [
migrations.AlterField(
model_name='workorderinvoice',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderinvoice',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='workorderinvoice',
name='image',
field=models.ImageField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Slika računa'),
),
migrations.AlterField(
model_name='workorderinvoice',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,433 @@
import uuid
import re
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from core.base_models import BaseModel
from decimal import Decimal
def _service_photo_upload_to(instance, filename):
"""
Putanja za upload:
fleet/{registration_number}/service_photos/{uuid4}_{original_filename}
Koristimo uuid jer instance.service_record.pk možda nije dostupna prije prvog save-a.
"""
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.service_record.vehicle.registration_number
except Exception:
# fallback
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/service_photos/{unique_name}"
def _service_attachment_upload_to(instance, filename):
"""
Putanja za upload:
fleet/{registration_number}/service_attachments/{uuid4}_{original_filename}
"""
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.service_record.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/service_attachments/{unique_name}"
def _work_order_invoice_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
class VehicleServicePhoto(BaseModel):
"""
Fotografije kvarova / radova povezane s VehicleServiceRecord.
Pohranjuju se slike i opcionalni opis te tko ih je upload-ao.
"""
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Servisni zapis")
)
image = models.ImageField(
upload_to=_service_photo_upload_to,
verbose_name=_("Fotografija")
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_service_photos',
verbose_name=_("Postavio")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija servisa")
verbose_name_plural = _("Fotografije servisa")
ordering = ['-created_at']
def __str__(self):
return f"Photo {self.pk} for {self.service_record} ({self.image.name if self.image else 'no-image'})"
class VehicleServiceAttachment(BaseModel):
"""
Datoteke (PDF, DOC, XLS...) povezane sa servisnim zapisom.
"""
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
on_delete=models.CASCADE,
related_name='attachments',
verbose_name=_("Servisni zapis")
)
file = models.FileField(
upload_to=_service_attachment_upload_to,
verbose_name=_("Datoteka")
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_service_attachments',
verbose_name=_("Postavio")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Prilog servisa")
verbose_name_plural = _("Prilozi servisa")
ordering = ['-created_at']
def __str__(self):
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
class Vehicle(BaseModel):
ASSET_TYPE_CHOICES = [
('vehicle', _("Vozilo")),
('crane', _("Dizalica")),
]
asset_type = models.CharField(
max_length=20,
choices=ASSET_TYPE_CHOICES,
default='crane',
db_index=True,
verbose_name=_("Tip sredstva"),
)
registration_number = models.CharField(max_length=20, unique=True, verbose_name=_("Registracija"))
make = models.CharField(max_length=100, blank=True, verbose_name=_("Proizvođač"))
model = models.CharField(max_length=100, blank=True, verbose_name=_("Model"))
year = models.PositiveSmallIntegerField(null=True, blank=True, verbose_name=_("Godina"))
vin = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("VIN"))
crane_serial_number = models.CharField(
max_length=6,
null=True,
blank=True,
db_index=True,
verbose_name=_("Serijski broj dizalice"),
help_text=_("Serijski broj dizalice mora imati točno 6 znamenki."),
)
superstructure_working_hours = models.PositiveIntegerField(
default=0,
verbose_name=_("Radni sati nadogradnje"),
)
chassis_working_hours = models.PositiveIntegerField(
default=0,
verbose_name=_("Radni sati podvozja"),
)
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
assigned_servicer = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='assigned_vehicles',
null=True,
blank=True,
verbose_name=_("Serviser")
)
# Označava je li vozilo u vlasništvu naše tvrtke (fleet) ili kupca
is_company_vehicle = models.BooleanField(
default=True,
verbose_name=_("Vozilo tvrtke"),
help_text=_("Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.")
)
# Kupac kojemu pripada vozilo (samo za vozila kupaca)
client = models.ForeignKey(
'crm.Client',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='vehicles',
verbose_name=_("Kupac"),
help_text=_("Popunite samo za vozila kupaca (is_company_vehicle = Ne).")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivno"))
class Meta:
verbose_name = _("Vozilo")
verbose_name_plural = _("Vozila")
ordering = ['registration_number']
def __str__(self):
return f"{self.registration_number} - {self.make} {self.model}"
def clean(self):
super().clean()
if self.asset_type == 'crane':
if not self.crane_serial_number and self._state.adding:
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice je obavezan za dizalice.")})
if self.crane_serial_number and not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
elif self.crane_serial_number:
if not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
def next_service_at(self) -> int:
"""
Vrati kilometražu pri kojoj sljedeći servis pada, bazirano na trenutnoj kilometraži i intervalu.
Npr. current=12000, interval=15000 -> next_service_at = 15000
Npr. current=32000, interval=15000 -> next_service_at = 45000
"""
if self.service_interval_km <= 0:
return 0
blocks = (self.current_mileage // self.service_interval_km) + 1
return blocks * self.service_interval_km
class Crane(Vehicle):
class Meta:
proxy = True
verbose_name = _("Dizalica")
verbose_name_plural = _("Dizalice")
ordering = ['registration_number']
def __str__(self):
details = " ".join(part for part in [self.make, self.model] if part)
if details:
return f"{self.registration_number} - {details}"
return self.registration_number
class WorkOrder(BaseModel):
"""
Putni nalog / radni nalog koji prati kilometražu vozila tijekom zadatka na terenu.
Kad se postavi end_mileage, model ažurira vehicle.current_mileage ako je end_mileage veći.
"""
STATUS_CHOICES = [
('open', 'Otvoren'),
('closed', 'Zatvoren'),
]
PURPOSE_CHOICES = [
('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")),
]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_work_orders', verbose_name=_("Kreirao"))
date = models.DateField(auto_now_add=True, verbose_name=_("Datum"))
start_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Početna kilometraža"))
end_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Završna kilometraža"))
servicer_vehicle_registration = models.CharField(
max_length=20,
blank=True,
verbose_name=_("Vozilo servisera - registracija"),
)
servicer_vehicle_make_model = models.CharField(
max_length=120,
blank=True,
verbose_name=_("Vozilo servisera - marka/model"),
)
servicer_vehicle_start_mileage = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Vozilo servisera - početna kilometraža"),
)
servicer_vehicle_end_mileage = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Vozilo servisera - završna kilometraža"),
)
servicer_vehicle_fuel_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
)
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
class Meta:
verbose_name = _("Putni nalog")
verbose_name_plural = _("Putni nalozi")
ordering = ['-created_at']
def __str__(self):
return f"WO-{self.pk} {self.vehicle.registration_number} ({self.status})"
@property
def distance(self):
if self.end_mileage is None or self.start_mileage is None:
return None
return max(0, self.end_mileage - self.start_mileage)
def save(self, *args, **kwargs):
self.full_clean() # Provjera validnosti prije svega
updating_vehicle = False
if self.end_mileage is not None:
# Ako je end_mileage veći od trenutne vehicle.current_mileage, ažuriraj vozilo.
if self.end_mileage > (self.vehicle.current_mileage or 0):
self.vehicle.current_mileage = self.end_mileage
updating_vehicle = True
super().save(*args, **kwargs)
if updating_vehicle:
# Spremi vozilo (nakon što nalog ima PK)
self.vehicle.save()
def clean(self):
super().clean()
if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})
class WorkOrderInvoice(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='invoices',
verbose_name=_("Putni nalog"),
)
naziv_racuna = models.CharField(max_length=255, verbose_name=_("Naziv računa"))
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
opis = models.TextField(blank=True, verbose_name=_("Opis"))
image = models.ImageField(
upload_to=_work_order_invoice_upload_to,
null=True,
blank=True,
verbose_name=_("Slika računa"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='created_work_order_invoices',
verbose_name=_("Kreirao"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Račun putnog naloga")
verbose_name_plural = _("Računi putnog naloga")
ordering = ['-datum', '-created_at']
def __str__(self):
return f"{self.naziv_racuna} ({self.work_order_id})"
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.
"""
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='service_records', verbose_name=_("Vozilo"))
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
related_name='service_records',
null=True,
blank=True,
verbose_name=_("Radni zadatak"),
)
performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='performed_vehicle_services', verbose_name=_("Izvršio"))
service_title = models.CharField(max_length=255, blank=True, default='', verbose_name=_("Naziv servisnog zapisa"))
description = models.TextField(verbose_name=_("Opis posla"))
parts = models.TextField(blank=True, verbose_name=_("Korišteni dijelovi"))
cost = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name=_("Trošak"))
service_date = models.DateField(auto_now_add=True, verbose_name=_("Datum servisa"))
mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža pri servisu"))
next_service_due_at = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža sljedećeg servisa"))
class Meta:
verbose_name = _("Servisni zapis")
verbose_name_plural = _("Servisni zapisi")
ordering = ['-service_date']
def __str__(self):
mileage_display = self.mileage if self.mileage is not None else "-"
title = (self.service_title or '').strip()
if title:
return f"{title} ({self.vehicle.registration_number} @ {mileage_display})"
return f"Service {self.vehicle.registration_number} @ {mileage_display}"
def clean(self):
super().clean()
if self.task_id and self.vehicle_id:
task = self.task
if task.vehicle_id and task.vehicle_id != self.vehicle_id:
raise ValidationError({'task': _("Radni zadatak mora biti povezan s istom dizalicom kao servisni zapis.")})
def save(self, *args, **kwargs):
self.full_clean() # Provjera validnosti prije svega
# Ako nije zadan next_service_due_at, postavi prema vehicle.service_interval_km
if self.next_service_due_at is None and self.mileage is not None and self.vehicle and self.vehicle.service_interval_km:
self.next_service_due_at = self.mileage + self.vehicle.service_interval_km
# VAŽNO: ažuriraj vehicle.current_mileage PRIJE save-a kako bi refresh-om nakon toga korisnik vidio svježe podatke
if self.mileage is not None and self.mileage > (self.vehicle.current_mileage or 0):
self.vehicle.current_mileage = self.mileage
self.vehicle.save(update_fields=['current_mileage'])
super().save(*args, **kwargs)
class VehicleNotification(BaseModel):
"""
Jednostavni zapis notifikacije vezane uz fleet objekte.
Može se koristiti za in-app obavijesti ili kao log koji je poslan.
"""
LEVEL_CHOICES = [
('info', 'Info'),
('warning', 'Upozorenje'),
('critical', 'Kritično'),
]
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='vehicle_notifications',
verbose_name=_("Primatelj")
)
title = models.CharField(max_length=255, verbose_name=_("Naslov"))
message = models.TextField(verbose_name=_("Poruka"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default='info', verbose_name=_("Razina"))
is_sent = models.BooleanField(default=False, verbose_name=_("Poslano (e-mail)"))
is_read = models.BooleanField(default=False, verbose_name=_("Pročitano"))
class Meta:
verbose_name = _("Notifikacija za vozilo")
verbose_name_plural = _("Notifikacije za vozila")
ordering = ['-created_at']
def __str__(self):
return f"{self.title} -> {self.recipient} ({self.level})"

View File

@@ -0,0 +1,314 @@
from rest_framework import serializers
import re
from .models import (
Crane,
Vehicle,
VehicleNotification,
WorkOrder,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
)
from . import services
class VehicleSerializer(serializers.ModelSerializer):
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
client_name = serializers.SerializerMethodField()
class Meta:
model = Vehicle
fields = [
'id', 'asset_type', 'registration_number', 'make', 'model', 'year', 'vin',
'crane_serial_number',
'superstructure_working_hours', 'chassis_working_hours',
'current_mileage', 'service_interval_km',
'is_company_vehicle', 'client', 'client_name',
'assigned_servicer', 'is_active',
]
read_only_fields = ['id', 'current_mileage', 'client_name']
def get_client_name(self, obj):
if obj.client_id:
return obj.client.name
return None
def create(self, validated_data):
validated_data.setdefault('asset_type', 'vehicle')
return services.VehicleService.create_vehicle(validated_data)
def update(self, instance, validated_data):
validated_data.pop('asset_type', None)
return services.VehicleService.update_vehicle(instance, validated_data)
def validate_crane_serial_number(self, value):
if value in (None, ''):
return None
normalized = str(value).strip()
if not re.fullmatch(r"[0-9]{6}", normalized):
raise serializers.ValidationError('Serijski broj dizalice mora sadržavati točno 6 znamenki.')
return normalized
def validate(self, attrs):
asset_type = attrs.get('asset_type', getattr(self.instance, 'asset_type', None))
if asset_type is None and getattr(self.Meta, 'model', None) is Crane:
asset_type = 'crane'
serial = attrs.get('crane_serial_number', getattr(self.instance, 'crane_serial_number', None))
is_create = self.instance is None
if asset_type == 'crane' and not serial and is_create:
raise serializers.ValidationError({
'crane_serial_number': 'Serijski broj dizalice je obavezan za dizalice.'
})
return attrs
class CraneSerializer(VehicleSerializer):
display_name = serializers.SerializerMethodField()
class Meta(VehicleSerializer.Meta):
model = Crane
fields = VehicleSerializer.Meta.fields + ['display_name']
read_only_fields = VehicleSerializer.Meta.read_only_fields + ['display_name']
def get_display_name(self, obj):
details = " ".join(part for part in [obj.make, obj.model] if part)
return f"{obj.registration_number} {details}".strip()
def create(self, validated_data):
validated_data['asset_type'] = 'crane'
return services.VehicleService.create_vehicle(validated_data)
def update(self, instance, validated_data):
validated_data.pop('asset_type', None)
return services.VehicleService.update_vehicle(instance, validated_data)
class VehicleNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = VehicleNotification
fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'is_read', 'created_at']
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
# is_read is writable via the "mark_read" action, but normally read-only in list
class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField()
class Meta:
model = WorkOrder
fields = [
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'start_mileage', 'end_mileage',
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
'servicer_vehicle_fuel_cost',
'location', 'travel_start_at', 'travel_end_at',
'purpose', 'notes', 'status'
]
read_only_fields = ['id', 'date']
extra_kwargs = {
'vehicle': {'required': False},
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
'location': {'required': False, 'allow_blank': True},
'travel_start_at': {'required': False, 'allow_null': True},
'travel_end_at': {'required': False, 'allow_null': True},
}
def get_crane(self, obj):
return obj.vehicle_id
def get_crane_label(self, obj):
if not obj.vehicle_id:
return None
details = " ".join(part for part in [obj.vehicle.registration_number, obj.vehicle.make, obj.vehicle.model] if part)
return details.strip()
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)
if not validated_data.get('vehicle'):
raise serializers.ValidationError({
'vehicle': 'Odaberite dizalicu za putni nalog iz servisnog konteksta.'
})
if getattr(validated_data['vehicle'], 'asset_type', None) != 'crane':
raise serializers.ValidationError({
'vehicle': 'Za putni nalog morate odabrati dizalicu (asset_type=crane).'
})
if user and 'creator' not in validated_data:
validated_data['creator'] = user
return services.VehicleService.create_work_order(validated_data)
def update(self, instance, validated_data):
return services.VehicleService.update_work_order(instance, validated_data)
def validate(self, attrs):
current = getattr(self, 'instance', None)
travel_start_at = attrs.get('travel_start_at', getattr(current, 'travel_start_at', None))
travel_end_at = attrs.get('travel_end_at', getattr(current, 'travel_end_at', None))
location = attrs.get('location', getattr(current, 'location', None))
start_mileage = attrs.get('start_mileage', getattr(current, 'start_mileage', None))
end_mileage = attrs.get('end_mileage', getattr(current, 'end_mileage', None))
servicer_start_mileage = attrs.get('servicer_vehicle_start_mileage', getattr(current, 'servicer_vehicle_start_mileage', None))
servicer_end_mileage = attrs.get('servicer_vehicle_end_mileage', getattr(current, 'servicer_vehicle_end_mileage', None))
servicer_fuel_cost = attrs.get('servicer_vehicle_fuel_cost', getattr(current, 'servicer_vehicle_fuel_cost', None))
if (travel_start_at is None) ^ (travel_end_at is None):
raise serializers.ValidationError({'travel_end_at': 'Unesite i početak i kraj puta, ili ostavite oba prazna.'})
if travel_start_at is not None and not str(location or '').strip():
raise serializers.ValidationError({'location': 'Lokacija je obavezna kada je definiran putni nalog.'})
if travel_start_at and travel_end_at and travel_end_at < travel_start_at:
raise serializers.ValidationError({'travel_end_at': 'Kraj puta ne može biti prije početka puta.'})
if end_mileage is not None and start_mileage is None:
raise serializers.ValidationError({'start_mileage': 'Početna kilometraža je obavezna ako je upisana završna.'})
if start_mileage is not None and end_mileage is not None and end_mileage < start_mileage:
raise serializers.ValidationError({'end_mileage': 'Završna kilometraža mora biti veća ili jednaka početnoj.'})
if servicer_end_mileage is not None and servicer_start_mileage is None:
raise serializers.ValidationError({'servicer_vehicle_start_mileage': 'Početna kilometraža vozila servisera je obavezna ako je upisana završna.'})
if servicer_start_mileage is not None and servicer_end_mileage is not None and servicer_end_mileage <= servicer_start_mileage:
raise serializers.ValidationError({'servicer_vehicle_end_mileage': 'Završna kilometraža vozila servisera mora biti veća od početne.'})
if servicer_fuel_cost is not None and servicer_fuel_cost < 0:
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderInvoice
fields = [
'id',
'work_order',
'naziv_racuna',
'lokacija',
'datum',
'opis',
'image',
'created_by',
'created_at',
]
read_only_fields = ['id', 'created_by', 'created_at']
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_registration = serializers.SerializerMethodField()
photos_count = serializers.SerializerMethodField()
attachments_count = serializers.SerializerMethodField()
files_count = serializers.SerializerMethodField()
performed_by_name = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
task_label = serializers.SerializerMethodField()
class Meta:
model = VehicleServiceRecord
fields = [
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
'task', 'task_label',
'performed_by', 'performed_by_name',
'service_title', 'description', 'parts', 'cost',
'service_date', 'mileage', 'next_service_due_at',
'photos_count', 'attachments_count', 'files_count',
]
read_only_fields = [
'id', 'service_date', 'next_service_due_at',
'photos_count', 'attachments_count', 'files_count',
'performed_by_name', 'vehicle_registration', 'crane', 'crane_registration',
'task_label',
]
extra_kwargs = {
'performed_by': {'required': False},
'vehicle': {'required': False},
'mileage': {'required': False, 'allow_null': True},
}
def get_photos_count(self, obj):
# prefetch_related('photos') u viewsetu sprječava N+1
return obj.photos.count()
def get_crane(self, obj):
return obj.vehicle_id
def get_attachments_count(self, obj):
return obj.attachments.count()
def get_files_count(self, obj):
return self.get_photos_count(obj) + self.get_attachments_count(obj)
def get_performed_by_name(self, obj):
if obj.performed_by_id:
return obj.performed_by.get_full_name() or obj.performed_by.username
return None
def get_vehicle_registration(self, obj):
if obj.vehicle_id:
return obj.vehicle.registration_number
return None
def get_crane_registration(self, obj):
return self.get_vehicle_registration(obj)
def get_task_label(self, obj):
if not obj.task_id:
return None
return obj.task.title
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)
task = validated_data.get('task')
if not task:
raise serializers.ValidationError({
'task': 'Odaberite radni zadatak prije kreiranja servisnog zapisa.'
})
if not task.vehicle_id:
raise serializers.ValidationError({
'task': 'Odabrani radni zadatak nema dodijeljenu dizalicu.'
})
validated_data['vehicle'] = task.vehicle
if user and not getattr(user, 'is_staff', False):
validated_data['performed_by'] = user
elif user and 'performed_by' not in validated_data:
validated_data['performed_by'] = user
return services.VehicleService.create_service_record(validated_data)
class VehicleServicePhotoSerializer(serializers.ModelSerializer):
image = serializers.ImageField(required=True)
class Meta:
model = VehicleServicePhoto
fields = ['id', 'service_record', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'created_at', 'uploaded_by']
def create(self, validated_data):
# Ako postoji request user, postavi uploaded_by
request = self.context.get('request') if self.context else None
user = getattr(request, 'user', None)
if user and 'uploaded_by' not in validated_data:
validated_data['uploaded_by'] = user
# Kreiranje direktno kroz model je ok; može se delegirati u services ako želiš dodatnu logiku
return VehicleServicePhoto.objects.create(**validated_data)
class VehicleServiceAttachmentSerializer(serializers.ModelSerializer):
file = serializers.FileField(required=True)
class Meta:
model = VehicleServiceAttachment
fields = ['id', 'service_record', 'file', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'created_at', 'uploaded_by']
def create(self, validated_data):
request = self.context.get('request') if self.context else None
user = getattr(request, 'user', None)
if user and 'uploaded_by' not in validated_data:
validated_data['uploaded_by'] = user
return VehicleServiceAttachment.objects.create(**validated_data)

View File

@@ -0,0 +1,412 @@
from django.db import transaction
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework.exceptions import ValidationError
from modules.fleet.tasks import send_email_task
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification, VehicleServicePhoto
from infrastructure.pusher_service import PusherService
import logging
logger = logging.getLogger(__name__)
User = get_user_model()
class NotificationService:
@staticmethod
def create_notification(recipient=None, title="", message="", level="info", send_email=False, metadata=None):
# 1. Kreiraj notifikaciju u DB-u
notif = VehicleNotification.objects.create(
recipient=recipient,
title=title[:255],
message=message,
metadata=metadata or {},
level=level,
is_sent=False
)
# 2. Broadcast preko WebSocket (Pusher) tek nakon uspješnog commit-a
if recipient:
transaction.on_commit(lambda: PusherService.broadcast_notification(
user_id=recipient.id,
title=title,
message=message,
level=level,
notification_id=notif.id,
metadata=metadata
))
# 3. Slanje emaila ako je traženo
if send_email and recipient and getattr(recipient, 'email', None):
email_args = {
'recipient_email': recipient.email,
'subject': title,
'message': message,
'notif_id': notif.id
}
if settings.DEBUG or getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
send_email_task(**email_args)
else:
transaction.on_commit(lambda: send_email_task.delay(**email_args))
return notif
class VehicleService:
@staticmethod
def create_vehicle(data: dict) -> Vehicle:
vehicle = Vehicle(**data)
try:
vehicle.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
vehicle.save()
return vehicle
@staticmethod
def update_vehicle(instance: Vehicle, data: dict) -> Vehicle:
for k, v in data.items():
setattr(instance, k, v)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
instance.save()
return instance
@staticmethod
def create_work_order(data: dict) -> WorkOrder:
# expecting creator in data
wo = WorkOrder(**data)
try:
wo.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
wo.save()
# obavijesti creatora i serviser-a vozila
try:
# Notify the creator
NotificationService.create_notification(
recipient=wo.creator,
title=f"Putni nalog kreiran: WO-{wo.pk}",
message=f"Putni nalog za dizalicu {wo.vehicle.registration_number} je kreiran.",
metadata={
"entity_type": "work_order",
"work_order_id": str(wo.pk),
"vehicle_id": str(wo.vehicle_id),
"crane_id": str(wo.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
# Notify assigned servicer if present
servicer = wo.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Novi putni nalog za {wo.vehicle.registration_number}",
message=f"Imate novi putni nalog (WO-{wo.pk}) za dizalicu {wo.vehicle.registration_number}.",
metadata={
"entity_type": "work_order",
"work_order_id": str(wo.pk),
"vehicle_id": str(wo.vehicle_id),
"crane_id": str(wo.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za create_work_order")
return wo
@staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
for k, v in data.items():
setattr(instance, k, v)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
instance.save()
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
if instance.status == 'closed':
try:
NotificationService.create_notification(
recipient=instance.creator,
title=f"Putni nalog zatvoren: WO-{instance.pk}",
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je zatvoren.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
servicer = instance.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Putni nalog zatvoren za {instance.vehicle.registration_number}",
message=f"Putni nalog (WO-{instance.pk}) je zatvoren.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za update_work_order")
return instance
@staticmethod
def delete_work_order(instance: WorkOrder, user=None) -> WorkOrder:
"""
Soft-delete putnog naloga. Samo creator ili staff može obrisati.
Vraća deaktivirani instance.
"""
# Provjera prava: samo creator ili staff
if not user:
raise ValidationError("Korisnik nije naveden za operaciju brisanja.")
if not (user.is_staff or instance.creator == user):
raise ValidationError("Nemate dozvolu za brisanje ovog putnog naloga.")
instance.is_active = False
instance.save(update_fields=['is_active'])
# Notifikacija o brisanju
try:
NotificationService.create_notification(
recipient=instance.creator,
title=f"Putni nalog obrisan: WO-{instance.pk}",
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je obrisan od strane {user}.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
"deleted": True,
},
level="warning",
send_email=False
)
servicer = instance.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Putni nalog obrisan za {instance.vehicle.registration_number}",
message=f"Putni nalog (WO-{instance.pk}) je obrisan.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
"deleted": True,
},
level="warning",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za delete_work_order")
logger.info(f"WorkOrder {instance.pk} soft-deleted by {user}")
return instance
@staticmethod
def create_service_record(data: dict) -> VehicleServiceRecord:
rec = VehicleServiceRecord(**data)
try:
rec.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
rec.save()
# update vehicle mileage if needed
v = rec.vehicle
if rec.mileage and rec.mileage > (v.current_mileage or 0):
v.current_mileage = rec.mileage
v.save()
# notify assigned servicer / creator
try:
# Notify performed_by user
NotificationService.create_notification(
recipient=rec.performed_by,
title=f"Servis zabilježen: {rec.vehicle.registration_number}",
message=(
f"Zabilježen servis na dizalici {rec.vehicle.registration_number}"
+ (f" pri kilometraži {rec.mileage}." if rec.mileage is not None else ".")
),
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
},
level="info",
send_email=False
)
# Notify vehicle assigned servicer if different
servicer = rec.vehicle.assigned_servicer
if servicer and servicer != rec.performed_by:
NotificationService.create_notification(
recipient=servicer,
title=f"Servis na vašem vozilu {rec.vehicle.registration_number}",
message=f"Servis je izvršen na dizalici {rec.vehicle.registration_number}.",
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
},
level="info",
send_email=False
)
# If next service is soon (e.g., within 1000 km), create a warning notification
next_due = rec.next_service_due_at
if next_due is None and rec.mileage is not None:
next_due = rec.mileage + rec.vehicle.service_interval_km
if next_due is not None and rec.mileage is not None and (next_due - rec.mileage) <= 1000:
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Uskoro servis za {rec.vehicle.registration_number}",
message=f"Sljedeći servis za dizalicu {rec.vehicle.registration_number} očekuje se na ~{next_due} km.",
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
"next_service_due_at": next_due,
},
level="warning",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za create_service_record")
return rec
class VehicleServicePhotoService:
"""
Servisna klasa za upravljanje VehicleServicePhoto zapisima.
Delegira upload, ažuriranje i brisanje s validacijom i sigurnosnim provjerama.
"""
@staticmethod
def upload_photo(data: dict, uploaded_by=None) -> VehicleServicePhoto:
"""
Kreiraj VehicleServicePhoto s file upload-om.
data treba sadržavati: service_record (ID ili instancu), image (UploadedFile), description (opcionalno).
"""
if not uploaded_by:
raise ValidationError("Korisnik je obavezan za upload fotografije.")
# Ako je service_record ID ili UUID, dohvati instancu
service_record = data.get('service_record')
# Provjeri je li već VehicleServiceRecord instanca
if not isinstance(service_record, VehicleServiceRecord):
# Pokušaj dohvatiti po ID-u (int, str, UUID)
try:
service_record = VehicleServiceRecord.objects.get(pk=service_record)
except (VehicleServiceRecord.DoesNotExist, ValueError, TypeError):
raise ValidationError("Servisni zapis ne postoji ili ID nije valjan.")
# Kreiraj novi dict s istim podacima ali sigurno postavljenim service_record
photo_data = {
'service_record': service_record,
'image': data.get('image'),
'description': data.get('description', ''),
'uploaded_by': uploaded_by
}
photo = VehicleServicePhoto(**photo_data)
try:
photo.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
photo.save()
logger.info(f"Fotografija uploadana od strane {uploaded_by} za servisni zapis {service_record.id}")
return photo
@staticmethod
def update_photo(instance: VehicleServicePhoto, data: dict, user=None) -> VehicleServicePhoto:
"""
Ažuriraj VehicleServicePhoto. Samo uploader ili staff mogu ažurirati.
"""
# Sigurnosna provjera: samo uploader ili staff mogu ažurirati
if not (user and (user.is_staff or instance.uploaded_by == user)):
raise ValidationError("Nemate dozvolu za ažuriranje ove fotografije.")
for attr, value in data.items():
if attr != 'image': # ne dozvoli ponovno upload slike (opciono)
setattr(instance, attr, value)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
instance.save()
logger.info(f"Fotografija {instance.id} ažurirana od strane {user}")
return instance
@staticmethod
def delete_photo(instance: VehicleServicePhoto, user=None) -> VehicleServicePhoto:
"""
Soft-delete VehicleServicePhoto. Samo uploader ili staff mogu obrisati.
"""
if not (user and (user.is_staff or instance.uploaded_by == user)):
raise ValidationError("Nemate dozvolu za brisanje ove fotografije.")
instance.is_active = False
instance.save(update_fields=['is_active'])
logger.info(f"Fotografija {instance.id} soft-deleted od strane {user}")
return instance
class FleetService:
@staticmethod
def bulk_close_work_orders(work_orders_qs):
updated_vehicles = {}
with transaction.atomic():
for wo in work_orders_qs:
# 1. Preskoči ako je već zatvoren
if wo.status == 'closed':
continue
# 2. NOVO: Preskoči ako nalog nije kompletan (nema kilometražu)
if wo.end_mileage is None:
continue
# 3. Zatvori nalog
wo.status = 'closed'
wo.save()
# 4. Ažuriraj vozilo
vehicle = wo.vehicle
current_max = updated_vehicles.get(vehicle.pk, vehicle.current_mileage)
if wo.end_mileage > current_max:
updated_vehicles[vehicle.pk] = wo.end_mileage
# Masovno ažuriranje vozila
for v_id, new_mileage in updated_vehicles.items():
Vehicle.objects.filter(pk=v_id).update(current_mileage=new_mileage)

View File

@@ -0,0 +1,165 @@
# backend/modules/fleet/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
import logging
from io import BytesIO
import base64
from PIL import Image, UnidentifiedImageError
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from .models import VehicleNotification
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_notification_email(self, notification_id):
"""
Celery task koji pošalje e-mail za VehicleNotification i označi is_sent = True.
Prihvaća notification_id (PK) i radi safe-lookup.
U slučaju greške rekreira retry.
"""
try:
# Lokalni import kako bismo izbjegli kružne importove
from .models import VehicleNotification
notif = VehicleNotification.objects.select_related('recipient').get(pk=notification_id)
except Exception as exc:
logger.warning(f"Notification {notification_id} ne postoji ili nije dostupna: {exc}")
return
recipient = notif.recipient
recipient_email = getattr(recipient, 'email', None) if recipient else None
if not recipient_email:
logger.info(f"Notification {notification_id} nema email primatelja; preskačem slanje.")
return
subject = notif.title
message = notif.message
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None)
try:
send_mail(
subject=subject,
message=message,
from_email=from_email,
recipient_list=[recipient_email],
fail_silently=False,
)
notif.is_sent = True
notif.save(update_fields=['is_sent'])
logger.info(f"Notification email poslan za Notification(id={notification_id}) na {recipient_email}")
except Exception as exc:
logger.exception(f"Greška pri slanju emaila za Notification(id={notification_id}): {exc}")
try:
# Retry with exponential backoff handled by celery config; here max_retries = 3
raise self.retry(exc=exc)
except self.MaxRetriesExceededError:
logger.error(f"Max retries exceeded za Notification(id={notification_id})")
@shared_task
def send_email_task(recipient_email, subject, message, notif_id):
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[recipient_email],
fail_silently=False,
)
VehicleNotification.objects.filter(id=notif_id).update(is_sent=True)
except Exception as exc:
logger.error(f"Async mail failed: {exc}")
def _build_work_order_invoices_pdf(work_order):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 50
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
y -= 22
pdf.setFont("Helvetica", 10)
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
y -= 16
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
if not invoices.exists():
y -= 24
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
pdf.save()
return buffer.getvalue()
for index, invoice in enumerate(invoices, start=1):
pdf.showPage()
y = height - 50
pdf.setFont("Helvetica-Bold", 13)
pdf.drawString(40, y, f"Račun #{index}")
y -= 22
pdf.setFont("Helvetica", 10)
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
y -= 16
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
y -= 16
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
y -= 16
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
y -= 20
if not invoice.image:
pdf.drawString(40, y, "slika: Nema slike.")
continue
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:
image = source.convert('RGB')
max_width = width - 80
max_height = y - 60
if max_height < 120:
pdf.showPage()
y = height - 60
max_height = y - 60
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
draw_width = max(1, int(image.width * ratio))
draw_height = max(1, int(image.height * ratio))
image_reader = ImageReader(image)
pdf.drawImage(
image_reader,
40,
y - draw_height,
width=draw_width,
height=draw_height,
preserveAspectRatio=True,
mask='auto',
)
except (UnidentifiedImageError, OSError):
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
finally:
invoice.image.close()
pdf.save()
return buffer.getvalue()
@shared_task
def build_work_order_invoices_pdf_task(work_order_id):
from .models import WorkOrder
work_order = WorkOrder.objects.select_related('vehicle').filter(pk=work_order_id).first()
if work_order is None:
return {"error": "Putni nalog nije pronađen."}
pdf_bytes = _build_work_order_invoices_pdf(work_order)
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
filename = f"{work_order.pk}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
}

View File

@@ -0,0 +1,18 @@
# conftest.py
import pytest
import uuid
from django.contrib.auth import get_user_model
@pytest.fixture
def test_user():
return get_user_model().objects.create_user(
username=f"user_{uuid.uuid4().hex[:8]}",
email=f"user+{uuid.uuid4().hex[:8]}@example.test",
password="pass"
)
@pytest.fixture(autouse=True)
def setup_test_celery_settings(settings):
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.CELERY_TASK_EAGER_PROPAGATES=True # Ovo osigurava da greške u tasku odmah pucaju u testu
settings.BROKER_BACKEND='memory', # Koristi in-memory broker umjesto Redis-a

View File

@@ -0,0 +1,72 @@
import pytest
from modules.fleet.models import Vehicle, WorkOrder
from modules.fleet.services import FleetService
from django.contrib.auth import get_user_model
@pytest.mark.django_db
def test_bulk_close_work_orders_updates_mileage(test_user):
# 1. Kreiraj vozilo
vehicle = Vehicle.objects.create(registration_number="ZG-001", current_mileage=1000)
# 2. Kreiraj više otvorenih naloga
wo1 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1200)
wo2 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1200, end_mileage=1500)
# 3. Pozovi bulk servis
queryset = WorkOrder.objects.filter(pk__in=[wo1.pk, wo2.pk])
FleetService.bulk_close_work_orders(queryset)
# 4. Provjeri rezultate
vehicle.refresh_from_db()
wo1.refresh_from_db()
wo2.refresh_from_db()
# Statusi moraju biti zatvoreni
assert wo1.status == 'closed'
assert wo2.status == 'closed'
# Kilometraža vozila mora biti najveća (1500)
assert vehicle.current_mileage == 1500
@pytest.mark.django_db
def test_bulk_close_ignores_already_closed_orders(test_user):
vehicle = Vehicle.objects.create(registration_number="ZG-002", current_mileage=1000)
# Već zatvoren nalog
wo = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1100, status='closed')
# Pozovi servis
FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
# Provjeri da nalog nije "pukao" niti promijenio stanje
wo.refresh_from_db()
assert wo.status == 'closed'
@pytest.mark.django_db
def test_bulk_close_ignores_incomplete_work_order(test_user):
vehicle = Vehicle.objects.create(registration_number="ZG-003", current_mileage=1000)
# Nalog koji je otvoren i NEMA end_mileage
wo = WorkOrder.objects.create(
vehicle=vehicle,
creator=test_user,
start_mileage=1000,
end_mileage=None, # Incomplete
status='open'
)
# Pozovi servis
FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
# Provjeri: Nalog bi trebao ostati otvoren ili se možda ne bi smio zatvoriti
# jer podaci nisu kompletni
wo.refresh_from_db()
vehicle.refresh_from_db()
# Ovisno o tvojoj poslovnoj logici:
# Ako nalog nije kompletan, ne bi ga se smjelo zatvoriti.
assert wo.status == 'open'
assert vehicle.current_mileage == 1000

View File

@@ -0,0 +1,67 @@
import uuid
from django.test import TestCase
from modules.fleet.serializers import CraneSerializer
class CraneSerialNumberTests(TestCase):
def _base_payload(self):
suffix = uuid.uuid4().hex[:6].upper()
return {
"registration_number": f"ZG-CRN-{suffix}",
"make": "Liebherr",
"model": "LTM",
"year": 2024,
"current_mileage": 12000,
"service_interval_km": 8000,
"is_company_vehicle": True,
"is_active": True,
}
def test_create_crane_with_serial_number_succeeds(self):
payload = {
**self._base_payload(),
"crane_serial_number": "123456",
}
serializer = CraneSerializer(data=payload)
self.assertTrue(serializer.is_valid(), serializer.errors)
crane = serializer.save()
self.assertEqual(crane.asset_type, "crane")
self.assertEqual(crane.crane_serial_number, "123456")
def test_create_crane_without_serial_number_fails(self):
payload = self._base_payload()
serializer = CraneSerializer(data=payload)
self.assertFalse(serializer.is_valid())
self.assertIn("crane_serial_number", serializer.errors)
def test_crane_serial_number_industry_standard_validation(self):
valid_payload = {
**self._base_payload(),
"registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
"crane_serial_number": "000123",
}
valid_serializer = CraneSerializer(data=valid_payload)
self.assertTrue(valid_serializer.is_valid(), valid_serializer.errors)
invalid_serials = [
"12345", # prekratko
"1234567", # predugo
"12A456", # slova nisu dozvoljena
"12 456", # razmaci nisu dozvoljeni
"12-456", # specijalni znakovi nisu dozvoljeni
"", # unicode znamenke nisu dozvoljene (samo ASCII 0-9)
]
for value in invalid_serials:
with self.subTest(serial=value):
payload = {
**self._base_payload(),
"registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
"crane_serial_number": value,
}
serializer = CraneSerializer(data=payload)
self.assertFalse(serializer.is_valid())
self.assertIn("crane_serial_number", serializer.errors)

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