This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user