patch oko teksta PDFa i UI tablica
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
email,first_name,last_name,password,is_serviser,is_kupac,is_team_member,is_staff,is_superuser,is_active,telefon,oib,occupation,residence,work_position,licence_number,is_verified,client_id,client_tax_id
|
||||
serviser1@example.com,Ime1,Prezime1,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
||||
serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
||||
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
|
||||
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
||||
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
|
||||
|
202
backend/core/users/management/commands/import_users_csv.py
Normal file
202
backend/core/users/management/commands/import_users_csv.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from modules.crm.models import Client
|
||||
|
||||
|
||||
def _to_bool(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||
return False
|
||||
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||
|
||||
|
||||
def _to_int(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Uvozi korisnike (radnike) iz CSV datoteke u CustomUser model."
|
||||
|
||||
expected_columns = [
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"password",
|
||||
"is_serviser",
|
||||
"is_kupac",
|
||||
"is_team_member",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_active",
|
||||
"telefon",
|
||||
"oib",
|
||||
"occupation",
|
||||
"residence",
|
||||
"work_position",
|
||||
"licence_number",
|
||||
"is_verified",
|
||||
"client_id",
|
||||
"client_tax_id",
|
||||
]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validira i simulira import bez spremanja u bazu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-only",
|
||||
action="store_true",
|
||||
help="Kreira samo nove korisnike; postojeće preskače.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-password",
|
||||
type=str,
|
||||
default="",
|
||||
help="Fallback lozinka za nove korisnike ako polje 'password' nije popunjeno.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||
dry_run = options["dry_run"]
|
||||
create_only = options["create_only"]
|
||||
default_password = options["default_password"]
|
||||
|
||||
if not csv_path.exists():
|
||||
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||
|
||||
User = get_user_model()
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
raise CommandError("CSV nema zaglavlje (header).")
|
||||
|
||||
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||
if missing:
|
||||
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||
|
||||
row_number = 1
|
||||
with transaction.atomic():
|
||||
for row in reader:
|
||||
row_number += 1
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
|
||||
email = (row.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
raise CommandError(f"Red {row_number}: email je obavezan.")
|
||||
|
||||
user = User.objects.filter(email__iexact=email).first()
|
||||
if user and create_only:
|
||||
skipped_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Red {row_number}: preskočeno (korisnik već postoji) {email}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
is_create = user is None
|
||||
if is_create:
|
||||
password = (row.get("password") or "").strip() or default_password
|
||||
if not password:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: password je obavezan za nove korisnike "
|
||||
"(ili koristite --default-password)."
|
||||
)
|
||||
user = User(email=email)
|
||||
user.set_password(password)
|
||||
else:
|
||||
password = (row.get("password") or "").strip()
|
||||
if password:
|
||||
user.set_password(password)
|
||||
|
||||
client = self._resolve_client(row=row, row_number=row_number)
|
||||
|
||||
user.first_name = (row.get("first_name") or "").strip()
|
||||
user.last_name = (row.get("last_name") or "").strip()
|
||||
user.telefon = (row.get("telefon") or "").strip()
|
||||
user.oib = (row.get("oib") or "").strip() or None
|
||||
user.occupation = (row.get("occupation") or "").strip()
|
||||
user.residence = (row.get("residence") or "").strip()
|
||||
user.work_position = (row.get("work_position") or "").strip()
|
||||
user.licence_number = (row.get("licence_number") or "").strip() or None
|
||||
|
||||
for bool_field in [
|
||||
"is_serviser",
|
||||
"is_kupac",
|
||||
"is_team_member",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_active",
|
||||
"is_verified",
|
||||
]:
|
||||
parsed = _to_bool(row.get(bool_field), bool_field)
|
||||
if parsed is not None:
|
||||
setattr(user, bool_field, parsed)
|
||||
|
||||
user.client_profile = client
|
||||
user.username = None
|
||||
|
||||
user.full_clean()
|
||||
if not dry_run:
|
||||
user.save()
|
||||
|
||||
if is_create:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} korisnik {email}"
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
summary = (
|
||||
f"Import korisnika završen. created={created_count}, updated={updated_count}, "
|
||||
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(summary))
|
||||
|
||||
def _resolve_client(self, *, row, row_number):
|
||||
client_id = _to_int(row.get("client_id"), "client_id")
|
||||
client_tax_id = (row.get("client_tax_id") or "").strip()
|
||||
|
||||
if client_id:
|
||||
client = Client.objects.filter(id=client_id).first()
|
||||
if not client:
|
||||
raise CommandError(f"Red {row_number}: klijent s ID={client_id} ne postoji.")
|
||||
return client
|
||||
|
||||
if client_tax_id:
|
||||
client = Client.objects.filter(tax_id=client_tax_id).first()
|
||||
if not client:
|
||||
raise CommandError(f"Red {row_number}: klijent s tax_id={client_tax_id} ne postoji.")
|
||||
return client
|
||||
|
||||
return None
|
||||
@@ -1,5 +1,4 @@
|
||||
from rest_framework import serializers
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from .models import CustomUser
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
'first_name': {'required': True, 'allow_blank': False}
|
||||
}
|
||||
|
||||
def _get_assigned_asset(self, obj):
|
||||
try:
|
||||
return obj.assigned_vehicles
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
def _get_assigned_assets(self, obj):
|
||||
return obj.assigned_vehicles.all()
|
||||
|
||||
def _serialize_asset(self, vehicle):
|
||||
if not vehicle:
|
||||
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
}
|
||||
|
||||
def get_assigned_vehicle(self, obj):
|
||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
||||
assigned = self._get_assigned_assets(obj)
|
||||
vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first()
|
||||
return self._serialize_asset(vehicle)
|
||||
|
||||
def get_assigned_crane(self, obj):
|
||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
||||
assigned = self._get_assigned_assets(obj)
|
||||
crane = assigned.filter(asset_type='crane').first() or assigned.first()
|
||||
return self._serialize_asset(crane)
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
@@ -22,4 +23,20 @@ def test_create_superuser():
|
||||
password="password123"
|
||||
)
|
||||
assert admin.is_superuser is True
|
||||
assert admin.is_staff is True
|
||||
assert admin.is_staff is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_groups_many_to_many_relation():
|
||||
user = User.objects.create_user(
|
||||
email="m2m@example.com",
|
||||
password="password123",
|
||||
)
|
||||
group_a = Group.objects.create(name="Serviseri")
|
||||
group_b = Group.objects.create(name="Dispeceri")
|
||||
|
||||
user.groups.add(group_a, group_b)
|
||||
|
||||
assert user.groups.count() == 2
|
||||
assert group_a.user_set.filter(id=user.id).exists()
|
||||
assert group_b.user_set.filter(id=user.id).exists()
|
||||
@@ -0,0 +1,11 @@
|
||||
name,client_type,tax_id,email,phone,address,city,postal_code
|
||||
Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000
|
||||
BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000
|
||||
BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000
|
||||
PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000
|
||||
Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000
|
||||
Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320
|
||||
Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000
|
||||
PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000
|
||||
Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000
|
||||
STRABAG,legal,10000000010,strabag@example.com,+38591111010,Ulica 10,Zagreb,10000
|
||||
|
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from modules.crm.models import Client
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Uvozi kupce (vlasnike dizalica) iz CSV datoteke u Client model."
|
||||
|
||||
expected_columns = [
|
||||
"name",
|
||||
"client_type",
|
||||
"tax_id",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
"city",
|
||||
"postal_code",
|
||||
]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validira i simulira import bez spremanja u bazu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-only",
|
||||
action="store_true",
|
||||
help="Kreira samo nove kupce; postojeće preskače.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||
dry_run = options["dry_run"]
|
||||
create_only = options["create_only"]
|
||||
|
||||
if not csv_path.exists():
|
||||
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
raise CommandError("CSV nema zaglavlje (header).")
|
||||
|
||||
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||
if missing:
|
||||
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||
|
||||
row_number = 1
|
||||
with transaction.atomic():
|
||||
for row in reader:
|
||||
row_number += 1
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
|
||||
tax_id = (row.get("tax_id") or "").strip()
|
||||
email = (row.get("email") or "").strip().lower()
|
||||
if not tax_id:
|
||||
raise CommandError(f"Red {row_number}: tax_id je obavezan.")
|
||||
if not email:
|
||||
raise CommandError(f"Red {row_number}: email je obavezan.")
|
||||
|
||||
client = Client.objects.filter(tax_id=tax_id).first()
|
||||
if client and create_only:
|
||||
skipped_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Red {row_number}: preskočeno (kupac već postoji) tax_id={tax_id}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
is_create = client is None
|
||||
if is_create:
|
||||
client = Client(tax_id=tax_id)
|
||||
|
||||
client_type = (row.get("client_type") or "legal").strip()
|
||||
if client_type not in {"legal", "individual"}:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
|
||||
)
|
||||
|
||||
client.name = (row.get("name") or "").strip()
|
||||
client.client_type = client_type
|
||||
client.email = email
|
||||
client.phone = (row.get("phone") or "").strip() or None
|
||||
client.address = (row.get("address") or "").strip()
|
||||
client.city = (row.get("city") or "").strip()
|
||||
client.postal_code = (row.get("postal_code") or "").strip()
|
||||
|
||||
try:
|
||||
client.full_clean()
|
||||
except ValidationError as exc:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: validacija nije prošla -> {exc.message_dict}"
|
||||
) from exc
|
||||
|
||||
if not dry_run:
|
||||
client.save()
|
||||
|
||||
if is_create:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} kupac {client.name}"
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
summary = (
|
||||
f"Import kupaca završen. created={created_count}, updated={updated_count}, "
|
||||
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(summary))
|
||||
@@ -0,0 +1,2 @@
|
||||
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,assigned_servicer_email,is_active
|
||||
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,true,,serviser1@example.com,true
|
||||
|
@@ -0,0 +1,2 @@
|
||||
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
|
||||
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,false,,Kupac Primjer d.o.o.,legal,12345678901,kupac.primjer@example.com,+38591111222,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||
|
@@ -0,0 +1,12 @@
|
||||
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
|
||||
ZG-LTM-001,210501,Liebherr,LTM 1350,2021,WLHZGLTM0012021AA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||
ZG-LTM-002,210502,Liebherr,LTM 1100,2022,WLHZGLTM0022022BB,2850,2410,96420,15000,false,,PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000,serviser2@example.com,true
|
||||
ZG-LTM-003,210503,Liebherr,LTM 1110,2023,WLHZGLTM0032023CC,1620,1395,54780,15000,false,,Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000,serviser3@example.com,true
|
||||
ZG-LTM-004,210504,Liebherr,LTM 1750,2021,WLHZGLTM0042021UA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||
ZG-LTM-005,210505,Liebherr,LTM 1500,2021,WLHZGLTM0052023AA,3400,2900,128450,15000,false,,BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000,serviser1@example.com,true
|
||||
ZG-LTM-006,210506,Liebherr,LTM 1060,2022,WLHZGLTM0062022BB,2850,2410,96420,15000,false,,BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000,serviser2@example.com,true
|
||||
ZG-LTM-007,210507,Liebherr,LTM 1060,2022,WLHZGLTM0072022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
|
||||
ZG-LTM-008,210508,Liebherr,LTM 1040,2022,WLHZGLTM0082022BB,2850,2410,96420,15000,false,,Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000,serviser3@example.com,true
|
||||
ZG-LTM-009,210509,Liebherr,LTM 1040,2022,WLHZGLTM0092022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
|
||||
ZG-LTM-010,210510,Liebherr,LTM 1250,2022,WLHZGLTM0102022BB,2850,2410,96420,15000,false,,PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000,serviser3@example.com,true
|
||||
ZG-LTM-011,210511,Liebherr,LTM 1090,2022,WLHZGLTM0112022BB,2850,2410,96420,15000,false,,Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000,serviser3@example.com,true
|
||||
|
@@ -0,0 +1,4 @@
|
||||
registration_number,make,model,year,vin,current_mileage,service_interval_km,assigned_servicer_email,is_active
|
||||
ZG-SRV-001,Volkswagen,Caddy,2021,WV1ZZZ2KZMX000001,128400,15000,serviser1@example.com,true
|
||||
ZG-SRV-002,Renault,Trafic,2022,VF1FL0000NY000002,96420,15000,serviser2@example.com,true
|
||||
ZG-SRV-003,Ford,Transit Custom,2023,WF0YXXTTGYPU00003,54780,15000,serviser3@example.com,true
|
||||
|
0
backend/modules/fleet/management/__init__.py
Normal file
0
backend/modules/fleet/management/__init__.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
@@ -0,0 +1,283 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from modules.crm.models import Client
|
||||
from modules.fleet.models import Vehicle
|
||||
|
||||
|
||||
def _to_bool(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||
return False
|
||||
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||
|
||||
|
||||
def _to_int(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Uvozi dizalice iz CSV datoteke i sprema ih u Vehicle model (asset_type=crane)."
|
||||
|
||||
expected_columns = [
|
||||
"registration_number",
|
||||
"crane_serial_number",
|
||||
"make",
|
||||
"model",
|
||||
"year",
|
||||
"vin",
|
||||
"superstructure_working_hours",
|
||||
"chassis_working_hours",
|
||||
"current_mileage",
|
||||
"service_interval_km",
|
||||
"is_company_vehicle",
|
||||
"client_id",
|
||||
"assigned_servicer_email",
|
||||
"is_active",
|
||||
]
|
||||
optional_client_columns = [
|
||||
"client_name",
|
||||
"client_type",
|
||||
"client_tax_id",
|
||||
"client_email",
|
||||
"client_phone",
|
||||
"client_address",
|
||||
"client_city",
|
||||
"client_postal_code",
|
||||
]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"csv_path",
|
||||
type=str,
|
||||
help="Apsolutna ili relativna putanja do CSV datoteke.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-only",
|
||||
action="store_true",
|
||||
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||
dry_run = options["dry_run"]
|
||||
create_only = options["create_only"]
|
||||
|
||||
if not csv_path.exists():
|
||||
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
raise CommandError("CSV nema zaglavlje (header).")
|
||||
|
||||
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||
if missing:
|
||||
raise CommandError(
|
||||
"CSV nema obavezna polja: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
row_number = 1
|
||||
with transaction.atomic():
|
||||
for row in reader:
|
||||
row_number += 1
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
|
||||
registration_number = (row.get("registration_number") or "").strip()
|
||||
crane_serial_number = (row.get("crane_serial_number") or "").strip()
|
||||
|
||||
if not registration_number:
|
||||
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
|
||||
if not crane_serial_number:
|
||||
raise CommandError(f"Red {row_number}: crane_serial_number je obavezan.")
|
||||
|
||||
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
|
||||
if vehicle and create_only:
|
||||
skipped_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if vehicle is None:
|
||||
vehicle = Vehicle(registration_number=registration_number)
|
||||
was_created = True
|
||||
else:
|
||||
was_created = False
|
||||
|
||||
assigned_servicer = None
|
||||
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip()
|
||||
if assigned_servicer_email:
|
||||
assigned_servicer = User.objects.filter(email=assigned_servicer_email).first()
|
||||
if not assigned_servicer:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji."
|
||||
)
|
||||
|
||||
is_company_vehicle = _to_bool(row.get("is_company_vehicle"), "is_company_vehicle")
|
||||
is_active = _to_bool(row.get("is_active"), "is_active")
|
||||
if is_company_vehicle is None:
|
||||
is_company_vehicle = True
|
||||
|
||||
client = self._resolve_or_create_client(
|
||||
row=row,
|
||||
row_number=row_number,
|
||||
is_company_vehicle=is_company_vehicle,
|
||||
)
|
||||
|
||||
vehicle.asset_type = "crane"
|
||||
vehicle.crane_serial_number = crane_serial_number
|
||||
vehicle.make = (row.get("make") or "").strip()
|
||||
vehicle.model = (row.get("model") or "").strip()
|
||||
vehicle.year = _to_int(row.get("year"), "year")
|
||||
vehicle.vin = (row.get("vin") or "").strip() or None
|
||||
vehicle.superstructure_working_hours = _to_int(
|
||||
row.get("superstructure_working_hours"),
|
||||
"superstructure_working_hours",
|
||||
) or 0
|
||||
vehicle.chassis_working_hours = _to_int(
|
||||
row.get("chassis_working_hours"),
|
||||
"chassis_working_hours",
|
||||
) or 0
|
||||
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
|
||||
vehicle.service_interval_km = _to_int(
|
||||
row.get("service_interval_km"),
|
||||
"service_interval_km",
|
||||
) or 15000
|
||||
if is_company_vehicle is not None:
|
||||
vehicle.is_company_vehicle = is_company_vehicle
|
||||
vehicle.client = client
|
||||
vehicle.assigned_servicer = assigned_servicer
|
||||
if is_active is not None:
|
||||
vehicle.is_active = is_active
|
||||
|
||||
try:
|
||||
vehicle.full_clean()
|
||||
except ValidationError as exc:
|
||||
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
|
||||
|
||||
if not dry_run:
|
||||
vehicle.save()
|
||||
|
||||
if was_created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} {registration_number}"
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
summary = (
|
||||
f"Import završen. created={created_count}, updated={updated_count}, "
|
||||
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(summary))
|
||||
|
||||
def _resolve_or_create_client(self, *, row, row_number, is_company_vehicle):
|
||||
client_id = _to_int(row.get("client_id"), "client_id")
|
||||
if client_id:
|
||||
client = Client.objects.filter(id=client_id).first()
|
||||
if not client:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: klijent s ID={client_id} ne postoji."
|
||||
)
|
||||
return client
|
||||
|
||||
client_tax_id = (row.get("client_tax_id") or "").strip()
|
||||
client_email = (row.get("client_email") or "").strip().lower()
|
||||
client_name = (row.get("client_name") or "").strip()
|
||||
|
||||
# Reuse pattern iz postojećih seed komandi: pokušaj pronaći postojeći zapis prije kreiranja.
|
||||
if client_tax_id:
|
||||
existing = Client.objects.filter(tax_id=client_tax_id).first()
|
||||
if existing:
|
||||
return existing
|
||||
if client_email:
|
||||
existing = Client.objects.filter(email__iexact=client_email).first()
|
||||
if existing:
|
||||
return existing
|
||||
if client_name:
|
||||
existing = Client.objects.filter(name=client_name).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
if is_company_vehicle:
|
||||
return None
|
||||
|
||||
if not any([client_tax_id, client_email, client_name]):
|
||||
raise CommandError(
|
||||
f"Red {row_number}: za klijentsku dizalicu (is_company_vehicle=false) "
|
||||
"morate navesti client_id ili client podatke za kreiranje kupca."
|
||||
)
|
||||
|
||||
missing_for_create = []
|
||||
create_fields = {
|
||||
"name": client_name,
|
||||
"client_type": (row.get("client_type") or "legal").strip() or "legal",
|
||||
"tax_id": client_tax_id,
|
||||
"email": client_email,
|
||||
"phone": (row.get("client_phone") or "").strip() or None,
|
||||
"address": (row.get("client_address") or "").strip(),
|
||||
"city": (row.get("client_city") or "").strip(),
|
||||
"postal_code": (row.get("client_postal_code") or "").strip(),
|
||||
}
|
||||
for required_key in ["name", "tax_id", "email", "address", "city", "postal_code"]:
|
||||
if not create_fields[required_key]:
|
||||
missing_for_create.append(required_key)
|
||||
|
||||
if missing_for_create:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: nedostaju podaci za kreiranje kupca: {', '.join(missing_for_create)}"
|
||||
)
|
||||
|
||||
if create_fields["client_type"] not in {"legal", "individual"}:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
|
||||
)
|
||||
|
||||
client, was_created = Client.objects.get_or_create(
|
||||
tax_id=create_fields["tax_id"],
|
||||
defaults=create_fields,
|
||||
)
|
||||
if was_created:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Red {row_number}: kreiran kupac '{client.name}' (OIB/VAT: {client.tax_id})"
|
||||
)
|
||||
)
|
||||
return client
|
||||
@@ -0,0 +1,267 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from modules.fleet.models import Vehicle
|
||||
|
||||
|
||||
def _to_bool(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||
return False
|
||||
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||
|
||||
|
||||
def _to_int(value, field_name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Uvozi vozila servisera iz CSV datoteke i sprema ih u Vehicle model "
|
||||
"(asset_type=vehicle, is_company_vehicle=true)."
|
||||
)
|
||||
|
||||
expected_columns = [
|
||||
"registration_number",
|
||||
"make",
|
||||
"model",
|
||||
"year",
|
||||
"vin",
|
||||
"current_mileage",
|
||||
"service_interval_km",
|
||||
"assigned_servicer_email",
|
||||
"is_active",
|
||||
]
|
||||
|
||||
users_template_columns = [
|
||||
"email",
|
||||
"is_serviser",
|
||||
]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"csv_path",
|
||||
type=str,
|
||||
help="Apsolutna ili relativna putanja do CSV datoteke vozila.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--users-csv-path",
|
||||
type=str,
|
||||
default="",
|
||||
help=(
|
||||
"Putanja do users_import_template.csv (fallback je "
|
||||
"backend/core/users/import_templates/users_import_template.csv)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-only",
|
||||
action="store_true",
|
||||
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
csv_path = self._resolve_input_csv_path(options["csv_path"])
|
||||
users_csv_path = self._resolve_users_csv_path(options.get("users_csv_path") or "")
|
||||
dry_run = options["dry_run"]
|
||||
create_only = options["create_only"]
|
||||
|
||||
if not csv_path.exists():
|
||||
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||
if not users_csv_path.exists():
|
||||
raise CommandError(f"users_import_template.csv ne postoji: {users_csv_path}")
|
||||
|
||||
allowed_servicer_emails = self._load_servicer_emails_from_template(users_csv_path)
|
||||
if not allowed_servicer_emails:
|
||||
raise CommandError(
|
||||
"users_import_template.csv ne sadrži nijednog servisera (is_serviser=true)."
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
raise CommandError("CSV nema zaglavlje (header).")
|
||||
|
||||
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||
if missing:
|
||||
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||
|
||||
row_number = 1
|
||||
with transaction.atomic():
|
||||
for row in reader:
|
||||
row_number += 1
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
|
||||
registration_number = (row.get("registration_number") or "").strip()
|
||||
if not registration_number:
|
||||
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
|
||||
|
||||
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip().lower()
|
||||
if not assigned_servicer_email:
|
||||
raise CommandError(f"Red {row_number}: assigned_servicer_email je obavezan.")
|
||||
if assigned_servicer_email not in allowed_servicer_emails:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: '{assigned_servicer_email}' nije valjan serviser "
|
||||
"u users_import_template.csv."
|
||||
)
|
||||
|
||||
assigned_servicer = User.objects.filter(email__iexact=assigned_servicer_email).first()
|
||||
if not assigned_servicer:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji u bazi."
|
||||
)
|
||||
if not assigned_servicer.is_serviser:
|
||||
raise CommandError(
|
||||
f"Red {row_number}: korisnik '{assigned_servicer_email}' nije označen kao serviser."
|
||||
)
|
||||
|
||||
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
|
||||
if vehicle and create_only:
|
||||
skipped_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if vehicle is None:
|
||||
vehicle = Vehicle(registration_number=registration_number)
|
||||
was_created = True
|
||||
else:
|
||||
was_created = False
|
||||
if vehicle.asset_type and vehicle.asset_type != "vehicle":
|
||||
raise CommandError(
|
||||
f"Red {row_number}: zapis {registration_number} već postoji kao "
|
||||
f"asset_type='{vehicle.asset_type}', ne može se prepisati u 'vehicle'."
|
||||
)
|
||||
|
||||
is_active = _to_bool(row.get("is_active"), "is_active")
|
||||
|
||||
vehicle.asset_type = "vehicle"
|
||||
vehicle.make = (row.get("make") or "").strip()
|
||||
vehicle.model = (row.get("model") or "").strip()
|
||||
vehicle.year = _to_int(row.get("year"), "year")
|
||||
vehicle.vin = (row.get("vin") or "").strip() or None
|
||||
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
|
||||
vehicle.service_interval_km = _to_int(
|
||||
row.get("service_interval_km"),
|
||||
"service_interval_km",
|
||||
) or 15000
|
||||
vehicle.is_company_vehicle = True
|
||||
vehicle.client = None
|
||||
vehicle.assigned_servicer = assigned_servicer
|
||||
if is_active is not None:
|
||||
vehicle.is_active = is_active
|
||||
|
||||
try:
|
||||
vehicle.full_clean()
|
||||
except ValidationError as exc:
|
||||
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
|
||||
|
||||
if not dry_run:
|
||||
vehicle.save()
|
||||
|
||||
if was_created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} "
|
||||
f"{registration_number} -> {assigned_servicer_email}"
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
summary = (
|
||||
f"Import vozila servisera završen. created={created_count}, updated={updated_count}, "
|
||||
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(summary))
|
||||
|
||||
def _resolve_input_csv_path(self, csv_path):
|
||||
resolved = Path(csv_path).expanduser().resolve()
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
|
||||
raw = str(csv_path).strip()
|
||||
candidates = []
|
||||
if "/backend/" in raw:
|
||||
candidates.append(raw.replace("/backend/", "/", 1))
|
||||
if "\\backend\\" in raw:
|
||||
candidates.append(raw.replace("\\backend\\", "\\", 1))
|
||||
if raw.startswith("backend/"):
|
||||
candidates.append(raw.replace("backend/", "", 1))
|
||||
if raw.startswith("backend\\"):
|
||||
candidates.append(raw.replace("backend\\", "", 1))
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_path = Path(candidate).expanduser().resolve()
|
||||
if candidate_path.exists():
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"CSV nije pronađen na '{resolved}', koristim '{candidate_path}'."
|
||||
)
|
||||
)
|
||||
return candidate_path
|
||||
return resolved
|
||||
|
||||
def _resolve_users_csv_path(self, users_csv_path):
|
||||
if users_csv_path:
|
||||
return Path(users_csv_path).expanduser().resolve()
|
||||
backend_root = Path(__file__).resolve().parents[4]
|
||||
return backend_root / "core" / "users" / "import_templates" / "users_import_template.csv"
|
||||
|
||||
def _load_servicer_emails_from_template(self, users_csv_path):
|
||||
with users_csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
raise CommandError("users_import_template.csv nema zaglavlje (header).")
|
||||
|
||||
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||
missing = [column for column in self.users_template_columns if column not in normalized_headers]
|
||||
if missing:
|
||||
raise CommandError(
|
||||
"users_import_template.csv nema obavezna polja: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
emails = set()
|
||||
row_number = 1
|
||||
for row in reader:
|
||||
row_number += 1
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
is_serviser = _to_bool(row.get("is_serviser"), f"is_serviser (users CSV, red {row_number})")
|
||||
email = (row.get("email") or "").strip().lower()
|
||||
if is_serviser and email:
|
||||
emails.add(email)
|
||||
return emails
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("fleet", "0020_alter_workorderinvoice_created_at_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="vehicle",
|
||||
name="assigned_servicer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="assigned_vehicles",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Serviser",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -149,7 +149,7 @@ class Vehicle(BaseModel):
|
||||
)
|
||||
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
|
||||
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
|
||||
assigned_servicer = models.OneToOneField(
|
||||
assigned_servicer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='assigned_vehicles',
|
||||
|
||||
128
backend/modules/fleet/pdf_layout.py
Normal file
128
backend/modules/fleet/pdf_layout.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import os
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
DEFAULT_MARGIN = 28
|
||||
DEFAULT_HEADER_H = 86
|
||||
DEFAULT_FOOTER_H = 72
|
||||
|
||||
|
||||
def register_unicode_fonts():
|
||||
if "Vera" in pdfmetrics.getRegisteredFontNames():
|
||||
return
|
||||
fonts_dir = os.path.join(os.path.dirname(__import__("reportlab").__file__), "fonts")
|
||||
pdfmetrics.registerFont(TTFont("Vera", os.path.join(fonts_dir, "Vera.ttf")))
|
||||
pdfmetrics.registerFont(TTFont("Vera-Bold", os.path.join(fonts_dir, "VeraBd.ttf")))
|
||||
pdfmetrics.registerFont(TTFont("Vera-Italic", os.path.join(fonts_dir, "VeraIt.ttf")))
|
||||
|
||||
|
||||
def draw_standard_header_footer(
|
||||
pdf,
|
||||
*,
|
||||
page_num,
|
||||
client_name,
|
||||
manufacturer,
|
||||
model,
|
||||
serial,
|
||||
upgrade_hours,
|
||||
chassis_hours,
|
||||
mileage,
|
||||
work_order_number,
|
||||
generated_date,
|
||||
report_title="Izvještaj servisera",
|
||||
page_size=A4,
|
||||
margin=DEFAULT_MARGIN,
|
||||
header_h=DEFAULT_HEADER_H,
|
||||
footer_h=DEFAULT_FOOTER_H,
|
||||
):
|
||||
w, h = page_size
|
||||
|
||||
pdf.setStrokeColor(colors.black)
|
||||
pdf.setLineWidth(0.7)
|
||||
pdf.rect(margin, h - header_h, w - 2 * margin, header_h - 4, stroke=1, fill=0)
|
||||
|
||||
pdf.setFont("Vera-Bold", 11)
|
||||
pdf.drawString(margin + 6, h - 22, str(client_name or "-")[:44])
|
||||
pdf.drawCentredString(w / 2, h - 22, report_title)
|
||||
pdf.setFont("Vera", 9)
|
||||
pdf.drawRightString(w - margin - 6, h - 22, f"Stranica {page_num}")
|
||||
|
||||
pdf.setLineWidth(0.5)
|
||||
pdf.line(margin, h - 36, w - margin, h - 36)
|
||||
|
||||
labels = [
|
||||
"Model:",
|
||||
"Serijski broj:",
|
||||
"Radni sati nadogradnje:",
|
||||
"Radni sati podvozja:",
|
||||
"Km:",
|
||||
"Broj naloga:",
|
||||
]
|
||||
values = [
|
||||
(manufacturer, model),
|
||||
serial,
|
||||
upgrade_hours,
|
||||
chassis_hours,
|
||||
mileage,
|
||||
work_order_number,
|
||||
]
|
||||
col_w = (w - 2 * margin) / len(labels)
|
||||
pdf.setFont("Vera", 7.2)
|
||||
for i, (lbl, val) in enumerate(zip(labels, values)):
|
||||
x = margin + i * col_w + 3
|
||||
pdf.drawString(x, h - 49, lbl)
|
||||
value_text = str(val or "-")
|
||||
value_font_size = 9
|
||||
if i == 0 and isinstance(val, tuple):
|
||||
manufacturer_text = str(val[0] or "-")[:24]
|
||||
model_text = str(val[1] or "-")[:24]
|
||||
pdf.setFont("Vera-Bold", 8.2)
|
||||
pdf.drawString(x, h - 59, manufacturer_text)
|
||||
pdf.setFont("Vera", 7.6)
|
||||
pdf.drawString(x, h - 68, model_text)
|
||||
elif i == len(labels) - 1:
|
||||
value_font_size = 7
|
||||
max_value_width = col_w - 8
|
||||
while (
|
||||
pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
|
||||
and value_font_size > 5.5
|
||||
):
|
||||
value_font_size -= 0.3
|
||||
if pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
|
||||
trimmed = value_text
|
||||
while (
|
||||
len(trimmed) > 3
|
||||
and pdf.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
|
||||
):
|
||||
trimmed = trimmed[:-1]
|
||||
value_text = f"{trimmed}..."
|
||||
else:
|
||||
value_text = value_text[:24]
|
||||
if not (i == 0 and isinstance(val, tuple)):
|
||||
pdf.setFont("Vera-Bold", value_font_size)
|
||||
pdf.drawString(x, h - 61, value_text)
|
||||
pdf.setFont("Vera", 7.2)
|
||||
if i > 0:
|
||||
pdf.line(margin + i * col_w, h - 36, margin + i * col_w, h - header_h + 4)
|
||||
|
||||
footer_y = footer_h
|
||||
pdf.setLineWidth(0.5)
|
||||
pdf.rect(margin, footer_y + 42, w - 2 * margin, 18, stroke=1, fill=0)
|
||||
sig_labels = [f"Datum: {generated_date}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
|
||||
sig_w = (w - 2 * margin) / 4
|
||||
pdf.setFont("Vera", 7.2)
|
||||
for i, lbl in enumerate(sig_labels):
|
||||
x = margin + i * sig_w
|
||||
pdf.drawString(x + 4, footer_y + 45, lbl)
|
||||
if i > 0:
|
||||
pdf.line(x, footer_y + 42, x, footer_y + 60)
|
||||
pdf.setFont("Vera", 6.5)
|
||||
pdf.drawString(margin, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
|
||||
pdf.drawCentredString(
|
||||
w / 2,
|
||||
footer_y + 14,
|
||||
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
from celery import shared_task
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import base64
|
||||
@@ -10,8 +11,10 @@ from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
from .models import VehicleNotification
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
register_unicode_fonts()
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
@@ -78,53 +81,93 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
width, height = A4
|
||||
y = height - 50
|
||||
margin = 28
|
||||
header_h = 86
|
||||
footer_h = 72
|
||||
content_top = height - header_h - 10
|
||||
content_bottom = footer_h + 52
|
||||
client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"
|
||||
manufacturer = str(work_order.vehicle.make or "-")
|
||||
model_line = str(work_order.vehicle.model or "-")
|
||||
serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-")
|
||||
upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-")
|
||||
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
|
||||
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
|
||||
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
|
||||
def draw_header_footer(page_num):
|
||||
draw_standard_header_footer(
|
||||
pdf,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer,
|
||||
model=model_line,
|
||||
serial=serial_line,
|
||||
upgrade_hours=upgrade_hours,
|
||||
chassis_hours=chassis_hours,
|
||||
mileage=mileage,
|
||||
work_order_number=str(work_order.pk),
|
||||
generated_date=generated_date,
|
||||
report_title="Računi putnog naloga",
|
||||
page_size=A4,
|
||||
margin=margin,
|
||||
header_h=header_h,
|
||||
footer_h=footer_h,
|
||||
)
|
||||
|
||||
page_num = 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
y -= 8
|
||||
|
||||
pdf.setFont("Vera-Bold", 13)
|
||||
pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
|
||||
y -= 20
|
||||
pdf.setFont("Vera", 10)
|
||||
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
|
||||
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
||||
if not invoices.exists():
|
||||
y -= 24
|
||||
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
|
||||
y -= 20
|
||||
pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.")
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
for index, invoice in enumerate(invoices, start=1):
|
||||
pdf.showPage()
|
||||
y = height - 50
|
||||
pdf.setFont("Helvetica-Bold", 13)
|
||||
pdf.drawString(40, y, f"Račun #{index}")
|
||||
page_num += 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
y -= 8
|
||||
pdf.setFont("Vera-Bold", 13)
|
||||
pdf.drawString(margin, y, f"Račun #{index}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||
pdf.setFont("Vera", 10)
|
||||
pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||
pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||
pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||
pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||
y -= 20
|
||||
|
||||
if not invoice.image:
|
||||
pdf.drawString(40, y, "slika: Nema slike.")
|
||||
pdf.drawString(margin, y, "slika: Nema slike.")
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB')
|
||||
max_width = width - 80
|
||||
max_height = y - 60
|
||||
max_width = width - (2 * margin)
|
||||
max_height = y - content_bottom
|
||||
if max_height < 120:
|
||||
pdf.showPage()
|
||||
y = height - 60
|
||||
max_height = y - 60
|
||||
page_num += 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
max_height = y - content_bottom
|
||||
|
||||
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
||||
draw_width = max(1, int(image.width * ratio))
|
||||
@@ -132,7 +175,7 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
image_reader = ImageReader(image)
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
40,
|
||||
margin,
|
||||
y - draw_height,
|
||||
width=draw_width,
|
||||
height=draw_height,
|
||||
@@ -140,7 +183,7 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
mask='auto',
|
||||
)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||
pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ from modules.fleet.models import Vehicle
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_servicer_one_to_one_constraint(test_user):
|
||||
|
||||
# Prvo vozilo prolazi
|
||||
def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
|
||||
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
|
||||
|
||||
# Drugo vozilo za istog servisera mora baciti IntegrityError
|
||||
with pytest.raises(Exception): # Django IntegrityError
|
||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||
|
||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||
|
||||
assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_next_service_logic():
|
||||
|
||||
@@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase):
|
||||
"creator": self.creator,
|
||||
"start_mileage": 10000,
|
||||
"end_mileage": 10120,
|
||||
"purpose": "Routine visit"
|
||||
"purpose": "kontrola"
|
||||
}
|
||||
|
||||
wo = VehicleService.create_work_order(data)
|
||||
|
||||
@@ -19,7 +19,7 @@ class FleetServiceTests(TestCase):
|
||||
)
|
||||
|
||||
def test_create_work_order_via_service(self):
|
||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
|
||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"}
|
||||
wo = VehicleService.create_work_order(data)
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(self.vehicle.current_mileage, 5200)
|
||||
|
||||
@@ -11,21 +11,13 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.db.models import Q
|
||||
import os
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Table, TableStyle, Paragraph
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
# Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž)
|
||||
_RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts')
|
||||
pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf')))
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
@@ -61,6 +53,8 @@ from .services import (
|
||||
)
|
||||
from .tasks import build_work_order_invoices_pdf_task
|
||||
|
||||
register_unicode_fonts()
|
||||
|
||||
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
|
||||
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
|
||||
if asset_type:
|
||||
@@ -226,6 +220,30 @@ def _build_work_order_pdf(work_order):
|
||||
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
||||
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
|
||||
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
|
||||
assigned_servicer_vehicle = (
|
||||
creator.assigned_vehicles
|
||||
.filter(asset_type='vehicle', is_active=True)
|
||||
.order_by('registration_number')
|
||||
.first()
|
||||
)
|
||||
assigned_servicer_vehicle_label = "-"
|
||||
assigned_servicer_vehicle_registration = "-"
|
||||
if assigned_servicer_vehicle:
|
||||
assigned_servicer_vehicle_label = " ".join(
|
||||
part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part
|
||||
).strip() or assigned_servicer_vehicle.registration_number
|
||||
assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-"
|
||||
|
||||
servicer_vehicle_label = (
|
||||
assigned_servicer_vehicle_label
|
||||
if assigned_servicer_vehicle
|
||||
else (work_order.servicer_vehicle_make_model or '-')
|
||||
)
|
||||
servicer_vehicle_registration = (
|
||||
assigned_servicer_vehicle_registration
|
||||
if assigned_servicer_vehicle
|
||||
else (work_order.servicer_vehicle_registration or '-')
|
||||
)
|
||||
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
@@ -331,7 +349,7 @@ def _build_work_order_pdf(work_order):
|
||||
[
|
||||
"1 dana" if travel_start else "-",
|
||||
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
||||
f"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}",
|
||||
f"{servicer_vehicle_label}, {servicer_vehicle_registration}",
|
||||
],
|
||||
["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
|
||||
[
|
||||
@@ -397,7 +415,7 @@ def _build_work_order_pdf(work_order):
|
||||
y,
|
||||
[
|
||||
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
|
||||
[place_label or '-', work_order.location or '-', work_order.servicer_vehicle_make_model or '-', str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
||||
[place_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
||||
["", "", "", "", "", ""],
|
||||
["", "", "", "", "", ""],
|
||||
],
|
||||
@@ -493,7 +511,8 @@ def _build_work_order_pdf(work_order):
|
||||
def _build_work_order_service_records_pdf(work_order):
|
||||
vehicle = work_order.vehicle
|
||||
client_name = getattr(vehicle.client, 'name', None) or '-'
|
||||
model_str = ' '.join(filter(None, [vehicle.make, vehicle.model])) or '-'
|
||||
manufacturer_str = str(vehicle.make or '-')
|
||||
model_str = str(vehicle.model or '-')
|
||||
serial_str = str(vehicle.crane_serial_number or '-')
|
||||
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
||||
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
||||
@@ -563,86 +582,24 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
super().save()
|
||||
|
||||
def _draw_header_footer(self):
|
||||
w, h = A4
|
||||
page_num = self._current_page
|
||||
|
||||
self.setStrokeColor(colors.black)
|
||||
self.setLineWidth(0.7)
|
||||
self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0)
|
||||
|
||||
self.setFont("Vera-Bold", 11)
|
||||
self.drawString(MARGIN + 6, h - 22, client_name[:44])
|
||||
self.drawCentredString(w / 2, h - 22, "Izvještaj servisera")
|
||||
self.setFont("Vera", 9)
|
||||
self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}")
|
||||
|
||||
self.setLineWidth(0.5)
|
||||
self.line(MARGIN, h - 36, w - MARGIN, h - 36)
|
||||
|
||||
labels = [
|
||||
"Model:",
|
||||
"Serijski broj:",
|
||||
"Radni sati nadogradnje:",
|
||||
"Radni sati podvozja:",
|
||||
"Km:",
|
||||
"Broj naloga:",
|
||||
]
|
||||
values = [
|
||||
model_str,
|
||||
serial_str,
|
||||
upgrade_hours_str,
|
||||
chassis_hours_str,
|
||||
mileage_str,
|
||||
nalog_str,
|
||||
]
|
||||
col_w = (w - 2 * MARGIN) / len(labels)
|
||||
self.setFont("Vera", 7.2)
|
||||
for i, (lbl, val) in enumerate(zip(labels, values)):
|
||||
x = MARGIN + i * col_w + 3
|
||||
self.drawString(x, h - 49, lbl)
|
||||
value_text = str(val or '-')
|
||||
value_font_size = 9
|
||||
if i == len(labels) - 1:
|
||||
value_font_size = 7
|
||||
max_value_width = col_w - 8
|
||||
while (
|
||||
self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
|
||||
and value_font_size > 5.5
|
||||
):
|
||||
value_font_size -= 0.3
|
||||
if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
|
||||
trimmed = value_text
|
||||
while (
|
||||
len(trimmed) > 3
|
||||
and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
|
||||
):
|
||||
trimmed = trimmed[:-1]
|
||||
value_text = f"{trimmed}..."
|
||||
else:
|
||||
value_text = value_text[:24]
|
||||
self.setFont("Vera-Bold", value_font_size)
|
||||
self.drawString(x, h - 61, value_text)
|
||||
self.setFont("Vera", 7.2)
|
||||
if i > 0:
|
||||
self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4)
|
||||
|
||||
footer_y = FOOTER_H
|
||||
self.setLineWidth(0.5)
|
||||
self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0)
|
||||
sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
|
||||
sig_w = (w - 2 * MARGIN) / 4
|
||||
self.setFont("Vera", 7.2)
|
||||
for i, lbl in enumerate(sig_labels):
|
||||
x = MARGIN + i * sig_w
|
||||
self.drawString(x + 4, footer_y + 45, lbl)
|
||||
if i > 0:
|
||||
self.line(x, footer_y + 42, x, footer_y + 60)
|
||||
self.setFont("Vera", 6.5)
|
||||
self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
|
||||
self.drawCentredString(
|
||||
w / 2,
|
||||
footer_y + 14,
|
||||
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
|
||||
draw_standard_header_footer(
|
||||
self,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer_str,
|
||||
model=model_str,
|
||||
serial=serial_str,
|
||||
upgrade_hours=upgrade_hours_str,
|
||||
chassis_hours=chassis_hours_str,
|
||||
mileage=mileage_str,
|
||||
work_order_number=nalog_str,
|
||||
generated_date=generated_date_str,
|
||||
report_title="Izvještaj servisera",
|
||||
page_size=A4,
|
||||
margin=MARGIN,
|
||||
header_h=HEADER_H,
|
||||
footer_h=FOOTER_H,
|
||||
)
|
||||
|
||||
pdf = PageCanvas(buffer, pagesize=A4)
|
||||
@@ -799,7 +756,7 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
|
||||
if not related_tasks:
|
||||
pdf.setFont("Vera", 9)
|
||||
pdf.drawString(MARGIN, y, "Nema povezanih taskova za ovaj putni nalog.")
|
||||
pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.")
|
||||
else:
|
||||
for task in related_tasks:
|
||||
task_records = [
|
||||
@@ -812,7 +769,7 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
if y < content_bottom + 60:
|
||||
y = new_page()
|
||||
pdf.setFont("Vera-Bold", 10)
|
||||
pdf.drawString(MARGIN, y, (task.title or f"Task #{task.id}")[:95])
|
||||
pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95])
|
||||
y -= 14
|
||||
|
||||
for record in task_records:
|
||||
@@ -912,6 +869,247 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _build_service_record_pdf(service_record):
|
||||
vehicle = service_record.vehicle
|
||||
task = getattr(service_record, 'task', None)
|
||||
work_order = getattr(task, 'work_order', None) if task else None
|
||||
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
||||
manufacturer_str = str(vehicle.make or '-')
|
||||
model_str = str(vehicle.model or '-')
|
||||
serial_str = str(vehicle.crane_serial_number or '-')
|
||||
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
||||
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
||||
mileage_str = str(vehicle.current_mileage or '-')
|
||||
nalog_str = str(getattr(work_order, 'pk', '-') or '-')
|
||||
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
|
||||
|
||||
HEADER_H = 86
|
||||
FOOTER_H = 72
|
||||
MARGIN = 28
|
||||
|
||||
def _fmt_date(value):
|
||||
if not value:
|
||||
return '-'
|
||||
return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value)
|
||||
|
||||
buffer = BytesIO()
|
||||
|
||||
class PageCanvas(canvas.Canvas):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._current_page = 0
|
||||
|
||||
def showPage(self):
|
||||
self._current_page += 1
|
||||
self._draw_header_footer()
|
||||
super().showPage()
|
||||
|
||||
def save(self):
|
||||
self._current_page += 1
|
||||
self._draw_header_footer()
|
||||
super().save()
|
||||
|
||||
def _draw_header_footer(self):
|
||||
page_num = self._current_page
|
||||
draw_standard_header_footer(
|
||||
self,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer_str,
|
||||
model=model_str,
|
||||
serial=serial_str,
|
||||
upgrade_hours=upgrade_hours_str,
|
||||
chassis_hours=chassis_hours_str,
|
||||
mileage=mileage_str,
|
||||
work_order_number=nalog_str,
|
||||
generated_date=generated_date_str,
|
||||
report_title="Izvještaj servisera",
|
||||
page_size=A4,
|
||||
margin=MARGIN,
|
||||
header_h=HEADER_H,
|
||||
footer_h=FOOTER_H,
|
||||
)
|
||||
|
||||
pdf = PageCanvas(buffer, pagesize=A4)
|
||||
page_w, page_h = A4
|
||||
content_top = page_h - HEADER_H - 10
|
||||
content_bottom = FOOTER_H + 52
|
||||
content_width = page_w - 2 * MARGIN
|
||||
|
||||
def new_page():
|
||||
pdf.showPage()
|
||||
return content_top
|
||||
|
||||
def draw_table(y, data, col_widths, style, row_heights=None):
|
||||
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
|
||||
table.setStyle(style)
|
||||
_, h = table.wrap(content_width, page_h)
|
||||
if y - h < content_bottom:
|
||||
y = new_page()
|
||||
table.drawOn(pdf, MARGIN, y - h)
|
||||
return y - h - 10
|
||||
|
||||
paragraph_styles = getSampleStyleSheet()
|
||||
description_style = paragraph_styles['BodyText'].clone('service-record-description')
|
||||
description_style.fontName = 'Vera'
|
||||
description_style.fontSize = 8.2
|
||||
description_style.leading = 10.4
|
||||
|
||||
y = content_top - 6
|
||||
y -= 6
|
||||
|
||||
info_rows = [
|
||||
["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)],
|
||||
["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'],
|
||||
["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'],
|
||||
["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')],
|
||||
["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')],
|
||||
]
|
||||
y = draw_table(
|
||||
y,
|
||||
info_rows,
|
||||
[78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
||||
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
|
||||
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
attachments = list(service_record.attachments.filter(is_active=True).all())
|
||||
attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file]
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image]
|
||||
if photos:
|
||||
if y < content_bottom + 30:
|
||||
y = new_page()
|
||||
y -= 8
|
||||
pdf.setFont("Vera", 8)
|
||||
pdf.drawString(MARGIN, y, "Fotografije:")
|
||||
y -= 10
|
||||
|
||||
image_gap = 10
|
||||
max_image_height = 110
|
||||
image_width = (content_width - image_gap) / 2
|
||||
for index in range(0, len(photos), 2):
|
||||
row_photos = photos[index:index + 2]
|
||||
prepared = []
|
||||
for photo in row_photos:
|
||||
try:
|
||||
image_reader = ImageReader(photo.image)
|
||||
source_w, source_h = image_reader.getSize()
|
||||
if not source_w or not source_h:
|
||||
continue
|
||||
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
|
||||
prepared.append((photo, image_reader, scaled_h))
|
||||
except (OSError, ValueError, TypeError, UnidentifiedImageError):
|
||||
continue
|
||||
if not prepared:
|
||||
continue
|
||||
|
||||
caption_height = 12
|
||||
row_height = max(item[2] for item in prepared) + caption_height
|
||||
if y - row_height < content_bottom:
|
||||
y = new_page()
|
||||
|
||||
base_y = y - caption_height
|
||||
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
|
||||
image_x = MARGIN + photo_index * (image_width + image_gap)
|
||||
image_y = base_y - scaled_h
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
image_x,
|
||||
image_y,
|
||||
width=image_width,
|
||||
height=scaled_h,
|
||||
preserveAspectRatio=True,
|
||||
anchor='c',
|
||||
mask='auto',
|
||||
)
|
||||
pdf.setFont("Vera", 7)
|
||||
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
|
||||
pdf.drawString(image_x, image_y - 10, caption[:52])
|
||||
|
||||
y = base_y - max(item[2] for item in prepared) - 14
|
||||
else:
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Fotografije"], ["-"]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
def _first_non_empty(*values):
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.strip():
|
||||
@@ -1294,18 +1492,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['get'], url_path='pdf')
|
||||
def pdf(self, request, pk=None):
|
||||
service_record = self.get_object()
|
||||
rows = [
|
||||
("ID", str(service_record.pk)),
|
||||
("Datum", str(service_record.service_date)),
|
||||
("Dizalica", service_record.vehicle.registration_number),
|
||||
("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email),
|
||||
("Kilometraza", str(service_record.mileage)),
|
||||
("Sljedeci servis", str(service_record.next_service_due_at or '-')),
|
||||
("Trosak", str(service_record.cost)),
|
||||
("Opis", service_record.description or '-'),
|
||||
("Dijelovi", service_record.parts or '-'),
|
||||
]
|
||||
pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
|
||||
pdf_bytes = _build_service_record_pdf(service_record)
|
||||
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
|
||||
return response
|
||||
@@ -1321,16 +1508,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
||||
if not recipient:
|
||||
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
|
||||
|
||||
rows = [
|
||||
("ID", str(service_record.pk)),
|
||||
("Datum", str(service_record.service_date)),
|
||||
("Dizalica", service_record.vehicle.registration_number),
|
||||
("Kilometraza", str(service_record.mileage)),
|
||||
("Trosak", str(service_record.cost)),
|
||||
("Opis", service_record.description or '-'),
|
||||
("Dijelovi", service_record.parts or '-'),
|
||||
]
|
||||
pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
|
||||
pdf_bytes = _build_service_record_pdf(service_record)
|
||||
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
|
||||
body = _first_non_empty(request.data.get('message')) or (
|
||||
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
|
||||
|
||||
@@ -10,6 +10,13 @@ from modules.crm.models import Client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@staticmethod
|
||||
def create_full_invoice(*args, **kwargs):
|
||||
from modules.invoicing.services import InvoiceService as _InvoiceService
|
||||
return _InvoiceService.create_full_invoice(*args, **kwargs)
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def process_paperless_document_task(self, document_id):
|
||||
"""
|
||||
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
|
||||
"created_at": raw_created,
|
||||
}
|
||||
|
||||
from modules.invoicing.services import InvoiceService
|
||||
|
||||
lookup_field = User.USERNAME_FIELD
|
||||
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class TaskService:
|
||||
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
|
||||
|
||||
payload = dict(data)
|
||||
title = (payload.get('title') or '').strip()
|
||||
if title and len(title) < 3:
|
||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||
if user and 'assigned_to' not in payload:
|
||||
payload['assigned_to'] = user
|
||||
|
||||
@@ -40,6 +43,8 @@ class TaskService:
|
||||
# Spremanje
|
||||
for attr, value in data.items():
|
||||
setattr(instance, attr, value)
|
||||
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
|
||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||
try:
|
||||
instance.full_clean()
|
||||
except DjangoValidationError as exc:
|
||||
|
||||
@@ -7,26 +7,28 @@ from modules.fleet.models import Vehicle, WorkOrder
|
||||
|
||||
|
||||
class TaskSerializerTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
|
||||
self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane')
|
||||
|
||||
def test_validate_title_too_short_raises(self):
|
||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
|
||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||
with self.assertRaises(ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
def test_validate_title_ok(self):
|
||||
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"})
|
||||
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test_cannot_close_without_work_order(self):
|
||||
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
|
||||
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)})
|
||||
with self.assertRaises(ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
def test_can_close_with_work_order(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass")
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane')
|
||||
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza')
|
||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user)
|
||||
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||
|
||||
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
from modules.task_management.views import TaskViewSet
|
||||
from modules.task_management.serializers import TaskSerializer
|
||||
from modules.task_management.models import Task
|
||||
from modules.fleet.models import Vehicle
|
||||
|
||||
|
||||
class TaskViewSetTests(TestCase):
|
||||
@@ -15,10 +16,11 @@ class TaskViewSetTests(TestCase):
|
||||
self.view = TaskViewSet()
|
||||
self.view.request = self.factory.post("/")
|
||||
self.view.request.user = self.user
|
||||
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
|
||||
|
||||
@patch("modules.task_management.views.TaskService.create_task_entry")
|
||||
def test_perform_create_calls_service(self, mock_create):
|
||||
data = {"title": "Task from view", "description": "", "status": "aktivan"}
|
||||
data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}
|
||||
serializer = TaskSerializer(data=data, context={"request": self.view.request})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = core.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
addopts = --import-mode=importlib
|
||||
Reference in New Issue
Block a user