This commit is contained in:
39
.gitea/workflows/ci.yml
Normal file
39
.gitea/workflows/ci.yml
Normal 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
|
||||
8
.github/instructions/NotificationSystem.instructions.md
vendored
Normal file
8
.github/instructions/NotificationSystem.instructions.md
vendored
Normal 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
211
.gitignore
vendored
Normal 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
20
backend/.dockerignore
Normal 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
35
backend/Dockerfile
Normal 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
0
backend/core/__init__.py
Normal file
34
backend/core/base_models.py
Normal file
34
backend/core/base_models.py
Normal 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
15
backend/core/celery.py
Normal 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()
|
||||
0
backend/core/management/__init__.py
Normal file
0
backend/core/management/__init__.py
Normal file
0
backend/core/management/commands/__init__.py
Normal file
0
backend/core/management/commands/__init__.py
Normal file
385
backend/core/management/commands/seed_all_models.py
Normal file
385
backend/core/management/commands/seed_all_models.py
Normal 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
|
||||
99
backend/core/management/commands/seed_fleet_assets.py
Normal file
99
backend/core/management/commands/seed_fleet_assets.py
Normal 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
|
||||
0
backend/core/management/commands/seed_knezljubo.py
Normal file
0
backend/core/management/commands/seed_knezljubo.py
Normal file
14
backend/core/serializers.py
Normal file
14
backend/core/serializers.py
Normal 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']
|
||||
8
backend/core/settings/__init__.py
Normal file
8
backend/core/settings/__init__.py
Normal 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 *
|
||||
215
backend/core/settings/base.py
Normal file
215
backend/core/settings/base.py
Normal 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',
|
||||
}
|
||||
}
|
||||
36
backend/core/settings/development.py
Normal file
36
backend/core/settings/development.py
Normal 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
|
||||
28
backend/core/settings/production.py
Normal file
28
backend/core/settings/production.py
Normal 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
33
backend/core/urls.py
Normal 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)
|
||||
0
backend/core/users/__init__.py
Normal file
0
backend/core/users/__init__.py
Normal file
27
backend/core/users/admin.py
Normal file
27
backend/core/users/admin.py
Normal 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',)
|
||||
7
backend/core/users/apps.py
Normal file
7
backend/core/users/apps.py
Normal 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'
|
||||
0
backend/core/users/management/__init__.py
Normal file
0
backend/core/users/management/__init__.py
Normal file
0
backend/core/users/management/commands/__init__.py
Normal file
0
backend/core/users/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from core.management.commands.seed_all_models import Command # noqa: F401
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from core.management.commands.seed_fleet_assets import Command # noqa: F401
|
||||
|
||||
107
backend/core/users/management/commands/seed_knezljubo.py
Normal file
107
backend/core/users/management/commands/seed_knezljubo.py
Normal 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
|
||||
52
backend/core/users/management/commands/seed_users.py
Normal file
52
backend/core/users/management/commands/seed_users.py
Normal 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!'))
|
||||
30
backend/core/users/managers.py
Normal file
30
backend/core/users/managers.py
Normal 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)
|
||||
47
backend/core/users/migrations/0001_initial.py
Normal file
47
backend/core/users/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
backend/core/users/migrations/__init__.py
Normal file
0
backend/core/users/migrations/__init__.py
Normal file
52
backend/core/users/models.py
Normal file
52
backend/core/users/models.py
Normal 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)
|
||||
73
backend/core/users/serializers.py
Normal file
73
backend/core/users/serializers.py
Normal 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
|
||||
35
backend/core/users/services.py
Normal file
35
backend/core/users/services.py
Normal 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
|
||||
25
backend/core/users/tests/test_models.py
Normal file
25
backend/core/users/tests/test_models.py
Normal 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
|
||||
37
backend/core/users/tests/test_permissions.py
Normal file
37
backend/core/users/tests/test_permissions.py
Normal 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"
|
||||
56
backend/core/users/tests/test_seed_all_models_command.py
Normal file
56
backend/core/users/tests/test_seed_all_models_command.py
Normal 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
|
||||
35
backend/core/users/tests/test_services.py
Normal file
35
backend/core/users/tests/test_services.py
Normal 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)
|
||||
61
backend/core/users/tests/test_users.py
Normal file
61
backend/core/users/tests/test_users.py
Normal 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"
|
||||
13
backend/core/users/urls.py
Normal file
13
backend/core/users/urls.py
Normal 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)),
|
||||
]
|
||||
44
backend/core/users/views.py
Normal file
44
backend/core/users/views.py
Normal 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
20
backend/core/utils.py
Normal 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
|
||||
0
backend/core/validators.py
Normal file
0
backend/core/validators.py
Normal file
16
backend/core/wsgi.py
Normal file
16
backend/core/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
101
backend/entrypoint.sh
Normal file
101
backend/entrypoint.sh
Normal 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
|
||||
0
backend/infrastructure/__init__.py
Normal file
0
backend/infrastructure/__init__.py
Normal file
43
backend/infrastructure/paperless_client.py
Normal file
43
backend/infrastructure/paperless_client.py
Normal 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', [])
|
||||
221
backend/infrastructure/pusher_service.py
Normal file
221
backend/infrastructure/pusher_service.py
Normal 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
|
||||
0
backend/infrastructure/webhooks.py
Normal file
0
backend/infrastructure/webhooks.py
Normal file
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
backend/modules/crm/__init__.py
Normal file
0
backend/modules/crm/__init__.py
Normal file
86
backend/modules/crm/admin.py
Normal file
86
backend/modules/crm/admin.py
Normal 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')
|
||||
8
backend/modules/crm/apps.py
Normal file
8
backend/modules/crm/apps.py
Normal 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"
|
||||
37
backend/modules/crm/migrations/0001_initial.py
Normal file
37
backend/modules/crm/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/modules/crm/migrations/__init__.py
Normal file
0
backend/modules/crm/migrations/__init__.py
Normal file
31
backend/modules/crm/models.py
Normal file
31
backend/modules/crm/models.py
Normal 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})"
|
||||
50
backend/modules/crm/serializers.py
Normal file
50
backend/modules/crm/serializers.py
Normal 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)})
|
||||
65
backend/modules/crm/services.py
Normal file
65
backend/modules/crm/services.py
Normal 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
|
||||
75
backend/modules/crm/tests/test_models.py
Normal file
75
backend/modules/crm/tests/test_models.py
Normal 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()
|
||||
43
backend/modules/crm/tests/test_serializers.py
Normal file
43
backend/modules/crm/tests/test_serializers.py
Normal 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)
|
||||
36
backend/modules/crm/tests/test_services.py
Normal file
36
backend/modules/crm/tests/test_services.py
Normal 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")
|
||||
62
backend/modules/crm/tests/test_views.py
Normal file
62
backend/modules/crm/tests/test_views.py
Normal 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)
|
||||
7
backend/modules/crm/urls.py
Normal file
7
backend/modules/crm/urls.py
Normal 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
|
||||
41
backend/modules/crm/views.py
Normal file
41
backend/modules/crm/views.py
Normal 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}")
|
||||
0
backend/modules/fleet/__init__.py
Normal file
0
backend/modules/fleet/__init__.py
Normal file
197
backend/modules/fleet/admin.py
Normal file
197
backend/modules/fleet/admin.py
Normal 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 "—"
|
||||
8
backend/modules/fleet/apps.py
Normal file
8
backend/modules/fleet/apps.py
Normal 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"
|
||||
124
backend/modules/fleet/migrations/0001_initial.py
Normal file
124
backend/modules/fleet/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
26
backend/modules/fleet/migrations/0006_crane_proxy_model.py
Normal file
26
backend/modules/fleet/migrations/0006_crane_proxy_model.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
18
backend/modules/fleet/migrations/0007_vehicle_asset_type.py
Normal file
18
backend/modules/fleet/migrations/0007_vehicle_asset_type.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
47
backend/modules/fleet/migrations/0015_workorderinvoice.py
Normal file
47
backend/modules/fleet/migrations/0015_workorderinvoice.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
backend/modules/fleet/migrations/__init__.py
Normal file
0
backend/modules/fleet/migrations/__init__.py
Normal file
433
backend/modules/fleet/models.py
Normal file
433
backend/modules/fleet/models.py
Normal 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})"
|
||||
314
backend/modules/fleet/serializers.py
Normal file
314
backend/modules/fleet/serializers.py
Normal 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)
|
||||
412
backend/modules/fleet/services.py
Normal file
412
backend/modules/fleet/services.py
Normal 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)
|
||||
165
backend/modules/fleet/tasks.py
Normal file
165
backend/modules/fleet/tasks.py
Normal 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,
|
||||
}
|
||||
18
backend/modules/fleet/tests/conftest.py
Normal file
18
backend/modules/fleet/tests/conftest.py
Normal 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
|
||||
72
backend/modules/fleet/tests/test_bulk_close_work_orders.py
Normal file
72
backend/modules/fleet/tests/test_bulk_close_work_orders.py
Normal 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
|
||||
67
backend/modules/fleet/tests/test_crane_serial_number.py
Normal file
67
backend/modules/fleet/tests/test_crane_serial_number.py
Normal 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
|
||||
"123456", # 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
Reference in New Issue
Block a user