patch oko teksta PDFa i UI tablica
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-12 03:25:03 +02:00
parent 3b54499b17
commit 4e3bf6dab0
40 changed files with 2149 additions and 331 deletions

View File

@@ -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
1 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
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 true serviser1@example.com true

View File

@@ -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
1 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
2 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

View File

@@ -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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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

View File

@@ -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
1 registration_number make model year vin current_mileage service_interval_km assigned_servicer_email is_active
2 ZG-SRV-001 Volkswagen Caddy 2021 WV1ZZZ2KZMX000001 128400 15000 serviser1@example.com true
3 ZG-SRV-002 Renault Trafic 2022 VF1FL0000NY000002 96420 15000 serviser2@example.com true
4 ZG-SRV-003 Ford Transit Custom 2023 WF0YXXTTGYPU00003 54780 15000 serviser3@example.com true

View 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

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -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',

View 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",
)

View File

@@ -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()

View File

@@ -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():

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')}."