feat: dodaj ishodište i PDF račune za putni nalog
Dodano je origin_location polje kroz backend model, serializer, admin i UI modal za kreiranje/uređivanje putnog naloga (default: Zagreb). Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza. Za greške uploada računa dodan je error toast kako bi poruke validacije bile vidljive korisniku odmah u sučelju. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -44,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
|
||||
class WorkOrderInline(admin.TabularInline):
|
||||
model = WorkOrder
|
||||
extra = 0
|
||||
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
|
||||
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
|
||||
readonly_fields = ('date',)
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class CraneAdmin(VehicleAdmin):
|
||||
|
||||
@admin.register(WorkOrder)
|
||||
class WorkOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
|
||||
list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
|
||||
list_filter = ('status', 'date', 'vehicle')
|
||||
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
|
||||
readonly_fields = ('date',)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0026_backfill_workorder_display_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workorder',
|
||||
name='origin_location',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
import modules.fleet.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0027_workorder_origin_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorderinvoice',
|
||||
name='image',
|
||||
field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'),
|
||||
),
|
||||
]
|
||||
@@ -285,6 +285,7 @@ class WorkOrder(BaseModel):
|
||||
blank=True,
|
||||
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
|
||||
)
|
||||
origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta"))
|
||||
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
|
||||
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
|
||||
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
|
||||
@@ -351,11 +352,11 @@ class WorkOrderInvoice(BaseModel):
|
||||
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
|
||||
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
|
||||
opis = models.TextField(blank=True, verbose_name=_("Opis"))
|
||||
image = models.ImageField(
|
||||
image = models.FileField(
|
||||
upload_to=_work_order_invoice_upload_to,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Slika računa"),
|
||||
verbose_name=_("Datoteka računa"),
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
import re
|
||||
from pathlib import Path
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
@@ -104,7 +105,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
||||
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
|
||||
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
|
||||
'servicer_vehicle_fuel_cost',
|
||||
'location', 'travel_start_at', 'travel_end_at',
|
||||
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
|
||||
'purpose', 'notes', 'status'
|
||||
]
|
||||
read_only_fields = ['id', 'date']
|
||||
@@ -119,6 +120,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
||||
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
|
||||
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
|
||||
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
|
||||
'origin_location': {'required': False, 'allow_blank': True},
|
||||
'location': {'required': False, 'allow_blank': True},
|
||||
'travel_start_at': {'required': False, 'allow_null': True},
|
||||
'travel_end_at': {'required': False, 'allow_null': True},
|
||||
@@ -211,6 +213,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
||||
|
||||
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
image_url = serializers.SerializerMethodField()
|
||||
image_content_type = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkOrderInvoice
|
||||
@@ -223,6 +226,7 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
'opis',
|
||||
'image',
|
||||
'image_url',
|
||||
'image_content_type',
|
||||
'created_by',
|
||||
'created_at',
|
||||
]
|
||||
@@ -238,6 +242,31 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
||||
)
|
||||
|
||||
def get_image_content_type(self, obj):
|
||||
if not obj.image:
|
||||
return None
|
||||
suffix = Path(obj.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
return 'application/pdf'
|
||||
if suffix in {'.jpg', '.jpeg'}:
|
||||
return 'image/jpeg'
|
||||
if suffix == '.png':
|
||||
return 'image/png'
|
||||
if suffix == '.webp':
|
||||
return 'image/webp'
|
||||
if suffix == '.gif':
|
||||
return 'image/gif'
|
||||
return 'application/octet-stream'
|
||||
|
||||
def validate_image(self, value):
|
||||
if not value:
|
||||
return value
|
||||
suffix = Path(getattr(value, 'name', '')).suffix.lower()
|
||||
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
|
||||
if suffix not in allowed_suffixes:
|
||||
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
|
||||
return value
|
||||
|
||||
|
||||
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils import timezone
|
||||
from django.core.files.base import ContentFile
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import base64
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
@@ -165,6 +166,11 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
pdf.drawString(margin, y, "slika: Nema slike.")
|
||||
continue
|
||||
|
||||
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).")
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with Image.open(invoice.image) as source:
|
||||
|
||||
@@ -22,6 +22,15 @@ def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
||||
)
|
||||
|
||||
|
||||
def create_test_pdf(filename='test.pdf'):
|
||||
content = b'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
|
||||
return SimpleUploadedFile(
|
||||
name=filename,
|
||||
content=content,
|
||||
content_type='application/pdf',
|
||||
)
|
||||
|
||||
|
||||
class WorkOrderImagesEndpointTests(TestCase):
|
||||
def setUp(self):
|
||||
user_model = get_user_model()
|
||||
@@ -138,3 +147,23 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
image_response = self.client.get(image_url)
|
||||
self.assertEqual(image_response.status_code, 200)
|
||||
self.assertTrue(image_response['Content-Type'].startswith('image/'))
|
||||
|
||||
def test_work_order_invoice_serializer_supports_pdf_file(self):
|
||||
invoice = WorkOrderInvoice.objects.create(
|
||||
work_order=self.work_order,
|
||||
naziv_racuna='PDF račun',
|
||||
image=create_test_pdf('invoice.pdf'),
|
||||
created_by=self.user,
|
||||
)
|
||||
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
|
||||
list_response = self.client.get(list_url)
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(len(list_response.data), 1)
|
||||
image_url = list_response.data[0].get('image_url')
|
||||
self.assertIsNotNone(image_url)
|
||||
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
|
||||
self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf')
|
||||
|
||||
file_response = self.client.get(image_url)
|
||||
self.assertEqual(file_response.status_code, 200)
|
||||
self.assertEqual(file_response['Content-Type'], 'application/pdf')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.conf import settings
|
||||
@@ -401,6 +402,7 @@ def _build_work_order_pdf(work_order):
|
||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
||||
grand_total = daily_total + transport_total
|
||||
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
||||
origin_label = (work_order.origin_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 = (
|
||||
@@ -598,7 +600,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 '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
||||
[origin_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)],
|
||||
["", "", "", "", "", ""],
|
||||
["", "", "", "", "", ""],
|
||||
],
|
||||
@@ -1601,7 +1603,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
<td>{escape(invoice.lokacija or '-')}</td>
|
||||
<td>{escape(datum)}</td>
|
||||
<td>{escape(invoice.opis or '-')}</td>
|
||||
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori sliku</a>' if image_url else '-'}</td>
|
||||
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori dokument</a>' if image_url else '-'}</td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
@@ -1791,6 +1793,17 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
||||
if not invoice.image:
|
||||
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
|
||||
|
||||
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
invoice.image.open('rb')
|
||||
try:
|
||||
response = HttpResponse(invoice.image.read(), content_type='application/pdf')
|
||||
finally:
|
||||
invoice.image.close()
|
||||
response['Cache-Control'] = 'private, max-age=86400'
|
||||
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"'
|
||||
return response
|
||||
|
||||
width = _parse_positive_int(
|
||||
request.query_params.get('w'),
|
||||
field_name='w',
|
||||
|
||||
Reference in New Issue
Block a user