1 Commits

Author SHA1 Message Date
mariomitte
61bc772054 fix: reduce memory usage for monthly ZIP archive flow
Replace generated archive download response with FileResponse streaming so ZIP files are not fully loaded into process memory.

Move monthly archive task output to a temporary file and upload that file to storage, and write invoice attachments into ZIP archives in chunks.

Add Celery memory guard settings, a memory-aware base task hook, and periodic /tmp cleanup for prefixed archive files older than one day.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-05 08:15:30 +02:00
10 changed files with 428 additions and 361 deletions

View File

@@ -1,37 +1,57 @@
# backend/core/celery.py # backend/core/celery.py
import os import os
from celery import Celery
from celery import Task
import gc import gc
import logging import logging
from celery import Celery
from celery import Task
logger = logging.getLogger(__name__)
# Postavi Django settings modul # Postavi Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core') app = Celery('core')
logger = logging.getLogger(__name__)
class ResourceAwareTask(Task):
"""Celery base task s hookovima za memory-heavy async operacije."""
abstract = True
def on_success(self, retval, task_id, args, kwargs):
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
super().on_success(retval, task_id, args, kwargs)
def on_failure(self, exc, task_id, args, kwargs, einfo):
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
super().on_failure(exc, task_id, args, kwargs, einfo)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
gc.collect()
super().after_return(status, retval, task_id, args, kwargs, einfo)
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_' # Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object('django.conf:settings', namespace='CELERY')
def _rss_memory_mb():
try:
import resource
usage_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
# Na Linuxu je ru_maxrss u KiB.
return round(float(usage_kb) / 1024.0, 2)
except (ImportError, AttributeError, OSError, ValueError):
return None
class MemoryAwareTask(Task):
abstract = True
def __call__(self, *args, **kwargs):
gc.collect()
return super().__call__(*args, **kwargs)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
memory_before_gc = _rss_memory_mb()
gc.collect()
memory_after_gc = _rss_memory_mb()
if memory_before_gc is not None and memory_after_gc is not None:
logger.info(
"Task %s (%s) status=%s RSS prije/poslije GC: %.2fMB -> %.2fMB",
self.name,
task_id,
status,
memory_before_gc,
memory_after_gc,
)
return super().after_return(status, retval, task_id, args, kwargs, einfo)
app.Task = MemoryAwareTask
# Automatski pronalazi taskove u svim 'modules.*' # Automatski pronalazi taskove u svim 'modules.*'
app.autodiscover_tasks() app.autodiscover_tasks()

View File

@@ -154,10 +154,11 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL CELERY_RESULT_BACKEND = REDIS_URL
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.environ.get('CELERY_WORKER_PREFETCH_MULTIPLIER', '1'))
CELERY_WORKER_MAX_TASKS_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_TASKS_PER_CHILD', '20'))
CELERY_WORKER_MAX_MEMORY_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_MEMORY_PER_CHILD', '350000'))
CELERY_TASK_ACKS_LATE = True CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20 CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse) # Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
# Za ovo ti je potreban paket 'django-redis' u requirements.txt # Za ovo ti je potreban paket 'django-redis' u requirements.txt
@@ -175,17 +176,17 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json' CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-archives-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
'schedule': crontab(minute=5),
},
'cleanup-expired-generated-pdfs-hourly': { 'cleanup-expired-generated-pdfs-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task', 'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0), 'schedule': crontab(minute=0),
}, },
'cleanup-expired-generated-archives-hourly': { 'cleanup-app-tmp-daily': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task', 'task': 'modules.fleet.tasks.cleanup_tmp_files_task',
'schedule': crontab(minute=10), 'schedule': crontab(hour=3, minute=15),
},
'cleanup-stale-tmp-archives-daily': {
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
'schedule': crontab(hour=3, minute=30),
}, },
'notify-upcoming-tasks-daily': { 'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks', 'task': 'modules.task_management.tasks.notify_upcoming_tasks',
@@ -202,6 +203,13 @@ STATIC_URL = '/static/'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente) # (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
APP_TMP_CLEANUP_DIR = os.environ.get('APP_TMP_CLEANUP_DIR', '/tmp')
APP_TMP_CLEANUP_MAX_AGE_HOURS = int(os.environ.get('APP_TMP_CLEANUP_MAX_AGE_HOURS', '24'))
APP_TMP_CLEANUP_PREFIXES = [
prefix.strip()
for prefix in os.environ.get('APP_TMP_CLEANUP_PREFIXES', 'erp-fleet-archive-').split(',')
if prefix.strip()
]
# ============================================================================== # ==============================================================================

View File

@@ -1,12 +1,11 @@
# backend/modules/fleet/tasks.py # backend/modules/fleet/tasks.py
from celery import shared_task from celery import shared_task
from core.celery import ResourceAwareTask
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files import File
import logging import logging
from io import BytesIO from io import BytesIO
from io import StringIO from io import StringIO
@@ -15,6 +14,9 @@ import base64
import re import re
import csv import csv
import mimetypes import mimetypes
import os
import tempfile
import zipfile
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from smtplib import SMTPSenderRefused from smtplib import SMTPSenderRefused
from datetime import timedelta from datetime import timedelta
@@ -837,12 +839,12 @@ def process_work_order_invoice_ocr(invoice_id):
return {"status": "ok", "invoice_id": str(invoice.pk)} return {"status": "ok", "invoice_id": str(invoice.pk)}
@shared_task(base=ResourceAwareTask) @shared_task
def build_monthly_archive_cached_task(generated_archive_id): def build_monthly_archive_cached_task(generated_archive_id):
from .models import GeneratedFleetArchive from .models import GeneratedFleetArchive
from .views import ( from .views import (
_build_monthly_service_tasks_archive_to_temp_file, _write_monthly_service_tasks_archive_entries,
_build_monthly_work_orders_archive_to_temp_file, _write_monthly_work_orders_archive_entries,
_notify_monthly_archive_request, _notify_monthly_archive_request,
) )
@@ -855,27 +857,39 @@ def build_monthly_archive_cached_task(generated_archive_id):
if generated is None: if generated is None:
return {"status": "failed", "error": "Generated archive record not found"} return {"status": "failed", "error": "Generated archive record not found"}
tmp_archive_path = None tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
with tempfile.NamedTemporaryFile(
mode='w+b',
delete=False,
dir=tmp_dir if os.path.isdir(tmp_dir) else None,
prefix='erp-fleet-archive-',
suffix='.zip',
) as temp_zip:
temp_zip_path = temp_zip.name
try: try:
with zipfile.ZipFile(temp_zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
if generated.archive_type == 'service_tasks': if generated.archive_type == 'service_tasks':
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file( entries_written = _write_monthly_service_tasks_archive_entries(
archive,
user=generated.requested_by, user=generated.requested_by,
year=generated.year, year=generated.year,
month=generated.month, month=generated.month,
) )
else: else:
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file( entries_written = _write_monthly_work_orders_archive_entries(
archive,
user=generated.requested_by, user=generated.requested_by,
year=generated.year, year=generated.year,
month=generated.month, month=generated.month,
) )
if entries_written == 0:
if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
raise ValueError('ZIP arhiva je prazna.') raise ValueError('ZIP arhiva je prazna.')
if os.path.getsize(temp_zip_path) <= 0:
raise ValueError('ZIP arhiva je prazna.')
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip" filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
with tmp_archive_path.open('rb') as temp_handle: with open(temp_zip_path, 'rb') as temp_file:
generated.file.save(filename, File(temp_handle), save=False) generated.file.save(filename, File(temp_file), save=False)
generated.status = 'ready' generated.status = 'ready'
generated.error_message = '' generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
@@ -906,8 +920,11 @@ def build_monthly_archive_cached_task(generated_archive_id):
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)} return {"status": "failed", "error": str(exc)}
finally: finally:
if tmp_archive_path is not None: try:
tmp_archive_path.unlink(missing_ok=True) if os.path.exists(temp_zip_path):
os.remove(temp_zip_path)
except OSError:
logger.warning("Ne mogu obrisati privremenu ZIP datoteku: %s", temp_zip_path)
@shared_task @shared_task
@@ -930,14 +947,6 @@ def cleanup_expired_generated_archives_task():
return {"deleted": deleted} return {"deleted": deleted}
@shared_task
def cleanup_stale_tmp_archives_task():
from .views import _cleanup_stale_tmp_archives
deleted = _cleanup_stale_tmp_archives()
return {"deleted": deleted}
@shared_task @shared_task
def cleanup_expired_generated_pdfs_task(): def cleanup_expired_generated_pdfs_task():
now = timezone.now() now = timezone.now()
@@ -958,6 +967,46 @@ def cleanup_expired_generated_pdfs_task():
return {"deleted": deleted} return {"deleted": deleted}
@shared_task
def cleanup_tmp_files_task():
tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
max_age_hours = int(getattr(settings, 'APP_TMP_CLEANUP_MAX_AGE_HOURS', 24))
prefixes = tuple(getattr(settings, 'APP_TMP_CLEANUP_PREFIXES', ['erp-fleet-archive-']))
if max_age_hours <= 0:
raise ValueError('APP_TMP_CLEANUP_MAX_AGE_HOURS mora biti > 0.')
if not prefixes:
raise ValueError('APP_TMP_CLEANUP_PREFIXES ne smije biti prazan.')
if not os.path.isdir(tmp_dir):
return {'deleted': 0, 'tmp_dir': tmp_dir, 'reason': 'tmp-dir-not-found'}
now = timezone.now().timestamp()
cutoff = now - (max_age_hours * 3600)
deleted = 0
for name in os.listdir(tmp_dir):
if not any(name.startswith(prefix) for prefix in prefixes):
continue
full_path = os.path.join(tmp_dir, name)
try:
stat_info = os.stat(full_path)
except FileNotFoundError:
continue
if stat_info.st_mtime > cutoff:
continue
if os.path.isdir(full_path):
try:
os.rmdir(full_path)
except OSError:
logger.warning("Preskačem %s: direktorij nije prazan ili je nedostupan.", full_path)
continue
else:
os.remove(full_path)
deleted += 1
return {'deleted': deleted, 'tmp_dir': tmp_dir, 'max_age_hours': max_age_hours}
@shared_task @shared_task
def build_work_order_pdf_cached_task(generated_pdf_id): def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf from .views import _build_work_order_pdf, _build_work_order_service_records_pdf

View File

@@ -497,67 +497,6 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('MT170726', text) self.assertIn('MT170726', text)
self.assertNotIn('MT150726', text) self.assertNotIn('MT150726', text)
def test_service_records_docx_includes_work_and_travel_total_row(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [{
'day': 'PON',
'date': '01.12.2033',
'work_time_from': '08:00',
'work_time_to': '12:30',
'travel_time_from': '07:00',
'travel_time_to': '08:00',
'break_hours': '0,5',
'work_hours': '4,0',
'travel_hours': '1,0',
'departure_place': 'Zagreb',
'arrival_place': 'Split',
'vehicle_km': '120',
}]
},
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}")
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
document_xml = archive.read('word/document.xml').decode('utf-8', errors='ignore')
self.assertIn('Ukupno', document_xml)
self.assertIn('4,0', document_xml)
self.assertIn('1,0', document_xml)
def test_service_records_pdf_includes_work_and_travel_total_row(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [{
'day': 'PON',
'date': '01.12.2033',
'work_time_from': '08:00',
'work_time_to': '12:30',
'travel_time_from': '07:00',
'travel_time_to': '08:00',
'break_hours': '0,5',
'work_hours': '4,0',
'travel_hours': '1,0',
'departure_place': 'Zagreb',
'arrival_place': 'Split',
'vehicle_km': '120',
}]
},
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/?task_id={self.task.pk}")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
reader = PdfReader(BytesIO(response.content))
text = "\n".join((page.extract_text() or '') for page in reader.pages)
self.assertIn('UKUPNO', text)
self.assertIn('4,0', text)
self.assertIn('1,0', text)
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self): def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response.status_code, 200, response.content)
@@ -639,40 +578,6 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertNotIn('Liebherr LTM 1090', second_header) self.assertNotIn('Liebherr LTM 1090', second_header)
self.assertNotIn('MT150726', second_header) self.assertNotIn('MT150726', second_header)
def test_monthly_work_orders_archive_includes_work_orders_for_user_even_without_assigned_task(self):
other_user = get_user_model().objects.create_user(
username=f'wo-other-{uuid.uuid4().hex[:8]}',
email=f'wo-other-{uuid.uuid4().hex[:8]}@example.test',
password='test1234',
)
linked_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT170726',
purpose='kontrola',
)
Task.objects.create(
title='Zadatak drugog servisera',
assigned_to=other_user,
vehicle=self.vehicle,
work_order=linked_work_order,
scheduled_date=date(2033, 12, 20),
)
WorkOrderInvoice.objects.create(
work_order=linked_work_order,
naziv_racuna='Prosinac račun osoba',
datum='2033-12-12',
image=create_test_pdf('racun-prosinac-osoba.pdf'),
created_by=self.user,
)
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
self.assertIn('MT150726.work-order.pdf', archive.namelist())
self.assertIn('MT170726.work-order.pdf', archive.namelist())
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self): def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
WorkOrderInvoice.objects.create( WorkOrderInvoice.objects.create(
work_order=self.work_order, work_order=self.work_order,

View File

@@ -5,7 +5,6 @@ from io import StringIO
import base64 import base64
import csv import csv
import mimetypes import mimetypes
import tempfile
import threading import threading
import zipfile import zipfile
import re import re
@@ -15,7 +14,7 @@ from pathlib import Path
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from PIL import Image, UnidentifiedImageError from PIL import Image, ImageOps, UnidentifiedImageError
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@@ -156,6 +155,7 @@ def _work_order_related_tasks_queryset(work_order):
vehicle_id=work_order.vehicle_id, vehicle_id=work_order.vehicle_id,
task_id__isnull=False, task_id__isnull=False,
task__is_active=True, task__is_active=True,
task__work_order=work_order,
).values_list('task_id', flat=True) ).values_list('task_id', flat=True)
) )
task_ids = list({*direct_task_ids, *inferred_task_ids}) task_ids = list({*direct_task_ids, *inferred_task_ids})
@@ -305,6 +305,14 @@ def _format_decimal_fixed(value, *, default='0.00', places=2):
return format(normalized.quantize(quantizer), 'f') return format(normalized.quantize(quantizer), 'f')
def _normalize_datetime_for_compare(value):
if not isinstance(value, datetime):
return value
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value)
def _work_order_travel_expenses_context(work_order): def _work_order_travel_expenses_context(work_order):
related_tasks = list(_work_order_related_tasks_queryset(work_order)) related_tasks = list(_work_order_related_tasks_queryset(work_order))
trip_entries = [] trip_entries = []
@@ -319,8 +327,16 @@ def _work_order_travel_expenses_context(work_order):
if trip_entries: if trip_entries:
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')] entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')] start_candidates = [
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')] _normalize_datetime_for_compare(entry.get('start_dt'))
for entry in trip_entries
if entry.get('start_dt')
]
end_candidates = [
_normalize_datetime_for_compare(entry.get('end_dt'))
for entry in trip_entries
if entry.get('end_dt')
]
if entry_dates: if entry_dates:
trip_start_date = min(entry_dates) trip_start_date = min(entry_dates)
trip_end_date = max(entry_dates) trip_end_date = max(entry_dates)
@@ -448,24 +464,32 @@ def _resolve_service_report_tasks(work_order, task_id):
return [selected_task], selected_task return [selected_task], selected_task
def _compress_image_for_pdf(image_field, max_width=1280, quality=75): _COMPRESS_IMAGE_MAX_MEGAPIXELS = 30 # preskači tek vrlo velike slike (>30 MP); standardni telefoni 1220 MP ostaju uključeni
def _compress_image_for_pdf(image_field, max_width=800, quality=75):
""" """
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna. vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
""" """
try: try:
image_field.open('rb') image_field.open('rb')
with Image.open(image_field) as src: with Image.open(image_field) as img:
img = src.convert('RGB') img = ImageOps.exif_transpose(img)
if img.width > max_width: w, h = img.size
ratio = max_width / float(img.width) if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
new_h = max(1, int(img.height * ratio)) return None
img = img.resize((max_width, new_h), Image.LANCZOS) img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
rgb = img.convert('RGB')
buf = BytesIO() buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True) rgb.save(buf, format='JPEG', quality=quality)
buf.seek(0) buf.seek(0)
return ImageReader(buf) return ImageReader(buf)
except Exception: except BaseException:
return None return None
finally: finally:
try: try:
@@ -474,23 +498,28 @@ def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
pass pass
def _compress_image_for_docx(image_field, max_width=1600, quality=80): def _compress_image_for_docx(image_field, max_width=800, quality=80):
""" """
Pripremi sliku za python-docx kao JPEG stream razumne veličine. Pripremi sliku za python-docx kao JPEG stream razumne veličine.
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
""" """
try: try:
image_field.open('rb') image_field.open('rb')
with Image.open(image_field) as src: with Image.open(image_field) as img:
img = src.convert('RGB') img = ImageOps.exif_transpose(img)
if img.width > max_width: w, h = img.size
ratio = max_width / float(img.width) if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
new_h = max(1, int(img.height * ratio)) return None
img = img.resize((max_width, new_h), Image.LANCZOS) img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
rgb = img.convert('RGB')
buf = BytesIO() buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True) rgb.save(buf, format='JPEG', quality=quality)
buf.seek(0) buf.seek(0)
return buf return buf
except Exception: except BaseException:
return None return None
finally: finally:
try: try:
@@ -517,10 +546,30 @@ def _get_cached_pdf(work_order, pdf_type):
) )
for candidate in candidates: for candidate in candidates:
if str(candidate.filename or '').strip() == expected_filename: if str(candidate.filename or '').strip() == expected_filename:
file_name = getattr(candidate.file, 'name', '')
storage = getattr(candidate.file, 'storage', None)
if file_name and storage:
try:
if storage.exists(file_name):
return candidate return candidate
except (FileNotFoundError, OSError, ValueError):
pass
candidate.file = None
candidate.is_active = False
candidate.status = 'failed'
candidate.error_message = 'PDF datoteka nije dostupna.'
candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
return None return None
def _mark_generated_pdf_failed(generated_pdf, error_message):
generated_pdf.file = None
generated_pdf.is_active = False
generated_pdf.status = 'failed'
generated_pdf.error_message = error_message
generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _cleanup_expired_generated_pdfs(): def _cleanup_expired_generated_pdfs():
now = timezone.now() now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter( expired = GeneratedWorkOrderPdf.objects.filter(
@@ -531,10 +580,11 @@ def _cleanup_expired_generated_pdfs():
for item in expired: for item in expired:
if item.file: if item.file:
item.file.delete(save=False) item.file.delete(save=False)
item.file = None
item.is_active = False item.is_active = False
item.status = 'failed' item.status = 'failed'
item.error_message = 'PDF cache istekao.' item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _parse_amount_decimal(value): def _parse_amount_decimal(value):
@@ -557,10 +607,11 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
for cached in cache_qs: for cached in cache_qs:
if cached.file: if cached.file:
cached.file.delete(save=False) cached.file.delete(save=False)
cached.file = None
cached.is_active = False cached.is_active = False
cached.status = 'failed' cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.' cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _upsert_additional_cost_row_from_invoice(invoice): def _upsert_additional_cost_row_from_invoice(invoice):
@@ -592,7 +643,11 @@ def _upsert_additional_cost_row_from_invoice(invoice):
def _cached_pdf_file_response(generated_pdf, *, default_filename): def _cached_pdf_file_response(generated_pdf, *, default_filename):
try:
generated_pdf.file.open('rb') generated_pdf.file.open('rb')
except (FileNotFoundError, OSError, ValueError):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
filename = generated_pdf.filename or default_filename filename = generated_pdf.filename or default_filename
response = FileResponse(generated_pdf.file, content_type='application/pdf') response = FileResponse(generated_pdf.file, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -1335,7 +1390,6 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
"Kilometri\nvozila", "Kilometri\nvozila",
] ]
table3_rows = [table3_headers] table3_rows = [table3_headers]
total_hours_work = 0.0
total_hours_travel = 0.0 total_hours_travel = 0.0
total_vehicle_km = 0.0 total_vehicle_km = 0.0
@@ -1352,24 +1406,13 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
row['places'], row['places'],
row['vehicle_km'], row['vehicle_km'],
]) ])
total_hours_work += _parse_decimal(row['work_hours'])
total_hours_travel += _parse_decimal(row['travel_hours']) total_hours_travel += _parse_decimal(row['travel_hours'])
total_vehicle_km += _parse_decimal(row['vehicle_km']) total_vehicle_km += _parse_decimal(row['vehicle_km'])
else: else:
for _ in range(12): for _ in range(12):
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"]) table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
table3_rows.append([ table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')])
"UKUPNO",
"",
"",
"",
"",
_format_decimal(total_hours_work),
_format_decimal(total_hours_travel),
"",
str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ','),
])
y = draw_table( y = draw_table(
y, y,
table3_rows, table3_rows,
@@ -1381,11 +1424,15 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, -1), 'CENTER'), ('ALIGN', (0, 0), (0, -1), 'CENTER'),
('ALIGN', (1, 0), (8, -1), 'CENTER'), ('ALIGN', (1, 0), (6, -1), 'CENTER'),
('SPAN', (0, -1), (5, -1)),
('ALIGN', (0, -1), (5, -1), 'LEFT'),
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'), ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
('FONTNAME', (5, -1), (8, -1), 'Vera-Bold'), ('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
('LEFTPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3),
('LEFTPADDING', (0, -1), (5, -1), 12),
('VALIGN', (7, 1), (7, -2), 'TOP'), ('VALIGN', (7, 1), (7, -2), 'TOP'),
('ALIGN', (7, 1), (7, -2), 'LEFT'), ('ALIGN', (7, 1), (7, -2), 'LEFT'),
]), ]),
@@ -2274,8 +2321,6 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
for index, header in enumerate(headers): for index, header in enumerate(headers):
hours_table.rows[0].cells[index].text = header hours_table.rows[0].cells[index].text = header
_docx_remove_rows_after(hours_table, keep_rows=1) _docx_remove_rows_after(hours_table, keep_rows=1)
total_work_hours = sum(_parse_report_decimal_value(row['work_hours']) for row in normalized_rows)
total_travel_hours = sum(_parse_report_decimal_value(row['travel_hours']) for row in normalized_rows)
if normalized_rows: if normalized_rows:
for row in normalized_rows: for row in normalized_rows:
cells = hours_table.add_row().cells cells = hours_table.add_row().cells
@@ -2292,16 +2337,6 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
cells = hours_table.add_row().cells cells = hours_table.add_row().cells
for index in range(9): for index in range(9):
cells[index].text = '-' cells[index].text = '-'
cells = hours_table.add_row().cells
cells[0].text = 'Ukupno'
cells[1].text = ''
cells[2].text = ''
cells[3].text = ''
cells[4].text = ''
cells[5].text = _format_decimal_display(total_work_hours, default='0')
cells[6].text = _format_decimal_display(total_travel_hours, default='0')
cells[7].text = ''
cells[8].text = ''
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati') _docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
_docx_cleanup_service_report_template(doc) _docx_cleanup_service_report_template(doc)
_docx_remove_empty_page_break_paragraphs(doc) _docx_remove_empty_page_break_paragraphs(doc)
@@ -2415,6 +2450,26 @@ def _file_attachment(file_field, fallback_name):
return (filename, content, _guess_content_type(filename)) return (filename, content, _guess_content_type(filename))
def _write_file_field_to_zip(archive, *, file_field, entry_name, chunk_size=64 * 1024):
if not file_field:
return False
file_field.open('rb')
try:
first_chunk = file_field.read(chunk_size)
if not first_chunk:
return False
with archive.open(entry_name, mode='w') as destination:
destination.write(first_chunk)
while True:
chunk = file_field.read(chunk_size)
if not chunk:
break
destination.write(chunk)
finally:
file_field.close()
return True
def _build_image_attachments_for_work_order(work_order): def _build_image_attachments_for_work_order(work_order):
attachments = [] attachments = []
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at') photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
@@ -2938,25 +2993,6 @@ def _generated_archive_filename_for_user(user, *, year, month, archive_type):
return f"{prefix}-{month:02d}-{year}-{suffix}.zip" return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
def _build_monthly_archive_to_temp_file(*, user, year, month, archive_type):
if archive_type == 'service_tasks':
archive_content = _build_monthly_service_tasks_archive_content(user=user, year=year, month=month)
else:
archive_content = _build_monthly_work_orders_archive_content(user=user, year=year, month=month)
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
temp_file.write(archive_content)
return Path(temp_file.name)
def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month):
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='service_tasks')
def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month):
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='work_orders')
def _unique_zip_entry_name(entry_name, used_names): def _unique_zip_entry_name(entry_name, used_names):
candidate = entry_name candidate = entry_name
entry_path = Path(entry_name) entry_path = Path(entry_name)
@@ -2992,7 +3028,7 @@ def _parse_year_month_params(request):
return year, month return year, month
def _build_monthly_service_tasks_archive_content(*, user, year, month): def _write_monthly_service_tasks_archive_entries(archive, *, user, year, month):
from modules.task_management.models import Task from modules.task_management.models import Task
tasks = list( tasks = list(
@@ -3012,8 +3048,7 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
used_names = set() used_names = set()
archive_buffer = BytesIO() entries_written = 0
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for task in tasks: for task in tasks:
work_order = task.work_order work_order = task.work_order
if work_order is None: if work_order is None:
@@ -3022,15 +3057,50 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
base_name = _service_records_docx_filename(work_order, task) base_name = _service_records_docx_filename(work_order, task)
entry_name = _unique_zip_entry_name(base_name, used_names) entry_name = _unique_zip_entry_name(base_name, used_names)
archive.writestr(entry_name, docx_bytes) archive.writestr(entry_name, docx_bytes)
entries_written += 1
if entries_written == 0:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return entries_written
def _build_monthly_service_tasks_archive_content(*, user, year, month):
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
_write_monthly_service_tasks_archive_entries(
archive,
user=user,
year=year,
month=month,
)
archive_content = archive_buffer.getvalue() archive_content = archive_buffer.getvalue()
if not archive_content: if not archive_content:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return archive_content return archive_content
def _build_monthly_work_orders_archive_content(*, user, year, month): def _write_monthly_work_orders_archive_entries(archive, *, user, year, month):
ordered_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month) from modules.task_management.models import Task
monthly_tasks = (
Task.objects
.filter(
assigned_to=user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.select_related('work_order')
.order_by('scheduled_date', 'created_at')
)
ordered_work_order_ids = []
seen_work_order_ids = set()
for task in monthly_tasks:
if not task.work_order_id or task.work_order_id in seen_work_order_ids:
continue
seen_work_order_ids.add(task.work_order_id)
ordered_work_order_ids.append(task.work_order_id)
if not ordered_work_order_ids: if not ordered_work_order_ids:
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
@@ -3047,23 +3117,34 @@ def _build_monthly_work_orders_archive_content(*, user, year, month):
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
used_names = set() used_names = set()
archive_buffer = BytesIO() entries_written = 0
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for work_order in work_orders: for work_order in work_orders:
pdf_bytes = _build_work_order_pdf(work_order) pdf_bytes = _build_work_order_pdf(work_order)
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names) work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
archive.writestr(work_order_pdf_name, pdf_bytes) archive.writestr(work_order_pdf_name, pdf_bytes)
entries_written += 1
display_code = _work_order_display_code(work_order) display_code = _work_order_display_code(work_order)
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at') invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
for index, invoice in enumerate(invoices, start=1): for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin") file_name = Path(str(getattr(getattr(invoice, 'image', None), 'name', '') or f"invoice-{index}.bin")).name
if not attachment: archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{file_name}", used_names)
continue if _write_file_field_to_zip(archive, file_field=invoice.image, entry_name=archive_path):
invoice_filename, content, _content_type = attachment entries_written += 1
archive_path = f"Racuni/{display_code}/{invoice_filename}" if entries_written == 0:
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content) raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return entries_written
def _build_monthly_work_orders_archive_content(*, user, year, month):
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
_write_monthly_work_orders_archive_entries(
archive,
user=user,
year=year,
month=month,
)
archive_content = archive_buffer.getvalue() archive_content = archive_buffer.getvalue()
if not archive_content: if not archive_content:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
@@ -3157,80 +3238,26 @@ def _get_cached_generated_archive(*, user, archive_type, year, month):
) )
def _user_monthly_work_order_ids(*, user, year, month):
from modules.task_management.models import Task
task_work_order_ids = set(
Task.objects
.filter(
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.filter(
Q(assigned_to=user)
| Q(work_order__creator=user)
| Q(vehicle__assigned_servicer=user)
)
.values_list('work_order_id', flat=True)
.distinct()
)
work_order_ids = set(
WorkOrder.objects
.filter(
is_active=True,
date__year=year,
date__month=month,
)
.filter(
Q(creator=user)
| Q(vehicle__assigned_servicer=user)
)
.values_list('id', flat=True)
.distinct()
)
return list(dict.fromkeys([*task_work_order_ids, *work_order_ids]))
def _latest_monthly_archive_source_update(*, user, archive_type, year, month): def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
from modules.task_management.models import Task from modules.task_management.models import Task
task_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
base_tasks = Task.objects.filter( base_tasks = Task.objects.filter(
assigned_to=user,
is_active=True, is_active=True,
scheduled_date__year=year, scheduled_date__year=year,
scheduled_date__month=month, scheduled_date__month=month,
work_order__isnull=False, work_order__isnull=False,
work_order__is_active=True, work_order__is_active=True,
).filter(
Q(assigned_to=user)
| Q(work_order__creator=user)
| Q(vehicle__assigned_servicer=user)
)
base_work_orders = WorkOrder.objects.filter(
is_active=True,
date__year=year,
date__month=month,
).filter(
Q(creator=user)
| Q(vehicle__assigned_servicer=user)
) )
latest_candidates = [ latest_candidates = [
base_tasks.aggregate(value=Max('updated_at')).get('value'), base_tasks.aggregate(value=Max('updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'),
base_work_orders.aggregate(value=Max('updated_at')).get('value'),
base_work_orders.aggregate(value=Max('vehicle__updated_at')).get('value'),
] ]
if archive_type == 'work_orders': if archive_type == 'work_orders':
latest_candidates.append( latest_candidates.append(
WorkOrderInvoice.objects.filter( base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value')
work_order_id__in=task_work_order_ids,
is_active=True,
).aggregate(value=Max('updated_at')).get('value')
) )
latest_values = [value for value in latest_candidates if value is not None] latest_values = [value for value in latest_candidates if value is not None]
if not latest_values: if not latest_values:
@@ -3428,12 +3455,18 @@ def generated_archive_download(request, archive_id):
if generated_archive is None: if generated_archive is None:
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'}) raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
generated_archive.file.open('rb') file_name = getattr(generated_archive.file, 'name', '')
storage = getattr(generated_archive.file, 'storage', None)
if not file_name or storage is None:
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
try: try:
archive_bytes = generated_archive.file.read() if not storage.exists(file_name):
finally: raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
generated_archive.file.close() except (FileNotFoundError, OSError, ValueError):
if not archive_bytes: raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
try:
generated_archive.file.open('rb')
except (FileNotFoundError, OSError, ValueError):
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'}) raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
filename = generated_archive.filename or _generated_archive_filename_for_user( filename = generated_archive.filename or _generated_archive_filename_for_user(
request.user, request.user,
@@ -3441,10 +3474,13 @@ def generated_archive_download(request, archive_id):
month=generated_archive.month, month=generated_archive.month,
archive_type=generated_archive.archive_type, archive_type=generated_archive.archive_type,
) )
response = HttpResponse(archive_bytes, content_type='application/zip') response = FileResponse(generated_archive.file, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Cache-Control'] = 'private, max-age=3600' response['Cache-Control'] = 'private, max-age=3600'
response['Content-Length'] = str(len(archive_bytes)) try:
response['Content-Length'] = str(generated_archive.file.size)
except (OSError, ValueError, TypeError):
pass
return response return response
@@ -3723,6 +3759,18 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
).exclude(file='').exclude(file__isnull=True).first() ).exclude(file='').exclude(file__isnull=True).first()
if generated_pdf is None: if generated_pdf is None:
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."}) raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
file_name = getattr(generated_pdf.file, 'name', '')
storage = getattr(generated_pdf.file, 'storage', None)
if not file_name or storage is None:
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
try:
if not storage.exists(file_name):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
except (FileNotFoundError, OSError, ValueError):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type)) return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
@action(detail=True, methods=['get'], url_path='pdf-preview') @action(detail=True, methods=['get'], url_path='pdf-preview')

View File

@@ -1,12 +0,0 @@
#!/bin/sh
set -eu
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
if [ ! -d "$TARGET_DIR" ]; then
exit 0
fi
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
find "$TARGET_DIR" -type d -empty -delete

View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
TMP_DIR="${APP_TMP_CLEANUP_DIR:-/tmp}"
MAX_AGE_DAYS="${APP_TMP_CLEANUP_MAX_AGE_DAYS:-1}"
PREFIXES="${APP_TMP_CLEANUP_PREFIXES:-erp-fleet-archive-}"
if [ ! -d "$TMP_DIR" ]; then
echo "TMP dir ne postoji: $TMP_DIR"
exit 0
fi
OLD_IFS="$IFS"
IFS=','
for prefix in $PREFIXES; do
prefix_trimmed="$(echo "$prefix" | xargs)"
if [ -z "$prefix_trimmed" ]; then
continue
fi
find "$TMP_DIR" -maxdepth 1 -mindepth 1 -name "${prefix_trimmed}*" -mtime "+${MAX_AGE_DAYS}" -print -delete
done
IFS="$OLD_IFS"
echo "TMP cleanup dovršen za $TMP_DIR (older than ${MAX_AGE_DAYS} day(s))."

View File

@@ -25,8 +25,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
worker: worker:
build: build:
@@ -39,7 +37,13 @@ services:
environment: environment:
DEBUG: "False" DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000 CELERY_WORKER_PREFETCH_MULTIPLIER: "1"
CELERY_WORKER_MAX_TASKS_PER_CHILD: "20"
CELERY_WORKER_MAX_MEMORY_PER_CHILD: "350000"
APP_TMP_CLEANUP_DIR: "/tmp"
APP_TMP_CLEANUP_MAX_AGE_HOURS: "24"
APP_TMP_CLEANUP_PREFIXES: "erp-fleet-archive-"
command: celery -A core worker --loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
volumes: volumes:
- media_volume:/app/media - media_volume:/app/media
depends_on: depends_on:
@@ -47,8 +51,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
beat: beat:
build: build:
@@ -69,8 +71,6 @@ services:
condition: service_started condition: service_started
worker: worker:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
flower: flower:
build: build:

View File

@@ -21,17 +21,6 @@ services:
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe. # i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
media-permissions:
image: alpine:3.22
container_name: 004erpmediafix
command: >
sh -c "mkdir -p /app/media/fleet/generated_archives /app/media/fleet/generated_pdfs
&& chown -R 1001:1001 /app/media
&& chmod -R u+rwX,g+rwX /app/media"
volumes:
- media_volume:/app/media
restart: "no"
worker: worker:
container_name: 004erpworker container_name: 004erpworker
build: build:
@@ -47,7 +36,6 @@ services:
depends_on: depends_on:
db: { condition: service_healthy } db: { condition: service_healthy }
redis: { condition: service_started } redis: { condition: service_started }
media-permissions: { condition: service_completed_successfully }
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -467,16 +467,19 @@ export const $dashboardStats = computed(
const servicesToday = tasks.filter( const servicesToday = tasks.filter(
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis') (t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
).length; ).length;
const warnings = serviceRecords.filter((record) => { const totalWorkOrders = workOrders.length;
if (record.next_service_due_at == null || record.mileage == null) return false; // Aktivni taskovi bez dodijeljenog putnog naloga
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000; const tasksWithoutWorkOrder = tasks.filter(
}).length; (t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak')
&& !t.work_order
).length;
return { return {
openWorkOrders, openWorkOrders,
closedWorkOrders, closedWorkOrders,
totalWorkOrders,
servicesToday, servicesToday,
warnings, tasksWithoutWorkOrder,
}; };
} }
); );
@@ -967,10 +970,30 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
return payload; return payload;
} }
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) { export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');
} }
if (taskId) {
try {
const blob = await api.get(
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
{ responseType: 'blob' },
);
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
const taskTitle = String(taskOptions?.taskTitle || '').trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_-]+/g, '')
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
return;
} catch (err) {
const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info'); showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) { if (payload?.status === 'ready' && payload?.download_url) {
@@ -999,14 +1022,28 @@ export async function downloadWorkOrderDocx(workOrderId) {
} }
} }
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) { export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');
} }
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
try { try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' }); const blob = await api.get(
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`,
{ responseType: 'blob' },
);
if (taskId) {
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
const taskTitle = String(taskOptions?.taskTitle || '').trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_-]+/g, '')
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
} else {
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`); saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
showToast('DOCX servisnih zapisa je preuzet.', 'success'); showToast('DOCX servisnih zapisa je preuzet.', 'success');
}
} catch (err) { } catch (err) {
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.'; const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
showToast(msg, 'error'); showToast(msg, 'error');