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

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

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

View File

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

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

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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