Refactor fleet module and add cleanup script
- Update Celery configuration in celery.py - Modify base settings for improved performance - Enhance task management in fleet tasks.py - Revise fleet views.py for better data handling - Add cleanup_tmp_archives.sh script for temporary file management - Adjust docker-compose.prod.yml for consistency - Optimize fleetDashboardStore.js with reduced code complexity
This commit is contained in:
@@ -5,6 +5,7 @@ from io import StringIO
|
||||
import base64
|
||||
import csv
|
||||
import mimetypes
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
import re
|
||||
@@ -14,7 +15,7 @@ from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
@@ -155,7 +156,6 @@ def _work_order_related_tasks_queryset(work_order):
|
||||
vehicle_id=work_order.vehicle_id,
|
||||
task_id__isnull=False,
|
||||
task__is_active=True,
|
||||
task__work_order=work_order,
|
||||
).values_list('task_id', flat=True)
|
||||
)
|
||||
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
||||
@@ -305,14 +305,6 @@ def _format_decimal_fixed(value, *, default='0.00', places=2):
|
||||
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):
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||
trip_entries = []
|
||||
@@ -327,16 +319,8 @@ def _work_order_travel_expenses_context(work_order):
|
||||
|
||||
if trip_entries:
|
||||
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
||||
start_candidates = [
|
||||
_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')
|
||||
]
|
||||
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
||||
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
|
||||
if entry_dates:
|
||||
trip_start_date = min(entry_dates)
|
||||
trip_end_date = max(entry_dates)
|
||||
@@ -400,6 +384,8 @@ def _parse_format(value):
|
||||
|
||||
GENERATED_PDF_TTL_HOURS = 24
|
||||
GENERATED_ARCHIVE_TTL_DAYS = 7
|
||||
TMP_ARCHIVE_RETENTION_HOURS = 24
|
||||
TMP_ARCHIVE_DIRNAME = 'erp-generated-archives'
|
||||
|
||||
|
||||
def _work_order_display_code(work_order):
|
||||
@@ -464,32 +450,24 @@ def _resolve_service_report_tasks(work_order, task_id):
|
||||
return [selected_task], selected_task
|
||||
|
||||
|
||||
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 30 # preskači tek vrlo velike slike (>30 MP); standardni telefoni 12–20 MP ostaju uključeni
|
||||
|
||||
|
||||
def _compress_image_for_pdf(image_field, max_width=800, quality=75):
|
||||
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||
"""
|
||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||
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:
|
||||
image_field.open('rb')
|
||||
with Image.open(image_field) as img:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
w, h = img.size
|
||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||
return None
|
||||
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||
rgb = img.convert('RGB')
|
||||
with Image.open(image_field) as src:
|
||||
img = src.convert('RGB')
|
||||
if img.width > max_width:
|
||||
ratio = max_width / float(img.width)
|
||||
new_h = max(1, int(img.height * ratio))
|
||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
rgb.save(buf, format='JPEG', quality=quality)
|
||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
buf.seek(0)
|
||||
return ImageReader(buf)
|
||||
except BaseException:
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
@@ -498,28 +476,23 @@ def _compress_image_for_pdf(image_field, max_width=800, quality=75):
|
||||
pass
|
||||
|
||||
|
||||
def _compress_image_for_docx(image_field, max_width=800, quality=80):
|
||||
def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
||||
"""
|
||||
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:
|
||||
image_field.open('rb')
|
||||
with Image.open(image_field) as img:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
w, h = img.size
|
||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||
return None
|
||||
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||
rgb = img.convert('RGB')
|
||||
with Image.open(image_field) as src:
|
||||
img = src.convert('RGB')
|
||||
if img.width > max_width:
|
||||
ratio = max_width / float(img.width)
|
||||
new_h = max(1, int(img.height * ratio))
|
||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
rgb.save(buf, format='JPEG', quality=quality)
|
||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
except BaseException:
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
@@ -546,30 +519,10 @@ def _get_cached_pdf(work_order, pdf_type):
|
||||
)
|
||||
for candidate in candidates:
|
||||
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
|
||||
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 candidate
|
||||
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():
|
||||
now = timezone.now()
|
||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||
@@ -580,11 +533,10 @@ def _cleanup_expired_generated_pdfs():
|
||||
for item in expired:
|
||||
if item.file:
|
||||
item.file.delete(save=False)
|
||||
item.file = None
|
||||
item.is_active = False
|
||||
item.status = 'failed'
|
||||
item.error_message = 'PDF cache istekao.'
|
||||
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _parse_amount_decimal(value):
|
||||
@@ -607,11 +559,10 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
||||
for cached in cache_qs:
|
||||
if cached.file:
|
||||
cached.file.delete(save=False)
|
||||
cached.file = None
|
||||
cached.is_active = False
|
||||
cached.status = 'failed'
|
||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
||||
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _upsert_additional_cost_row_from_invoice(invoice):
|
||||
@@ -643,11 +594,7 @@ def _upsert_additional_cost_row_from_invoice(invoice):
|
||||
|
||||
|
||||
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||
try:
|
||||
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."})
|
||||
generated_pdf.file.open('rb')
|
||||
filename = generated_pdf.filename or default_filename
|
||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
@@ -2988,27 +2935,40 @@ def _unique_zip_entry_name(entry_name, used_names):
|
||||
return candidate
|
||||
|
||||
|
||||
def _parse_year_month_params(request):
|
||||
from datetime import date as _dt_date
|
||||
|
||||
raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None
|
||||
raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None
|
||||
if raw_year in (None, ''):
|
||||
raw_year = request.query_params.get('year', _dt_date.today().year)
|
||||
if raw_month in (None, ''):
|
||||
raw_month = request.query_params.get('month', _dt_date.today().month)
|
||||
|
||||
try:
|
||||
year = int(raw_year)
|
||||
month = int(raw_month)
|
||||
if not (1 <= month <= 12):
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'})
|
||||
return year, month
|
||||
def _tmp_archive_dir():
|
||||
return Path(tempfile.gettempdir()) / TMP_ARCHIVE_DIRNAME
|
||||
|
||||
|
||||
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||
def _create_tmp_archive_path(*, prefix):
|
||||
target_dir = _tmp_archive_dir()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='wb',
|
||||
suffix='.zip',
|
||||
prefix=prefix,
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as handle:
|
||||
return Path(handle.name)
|
||||
|
||||
|
||||
def _cleanup_stale_tmp_archives(*, retention_hours=TMP_ARCHIVE_RETENTION_HOURS):
|
||||
target_dir = _tmp_archive_dir()
|
||||
if not target_dir.exists():
|
||||
return 0
|
||||
cutoff_ts = timezone.now().timestamp() - (retention_hours * 3600)
|
||||
deleted = 0
|
||||
for item in target_dir.glob('*.zip'):
|
||||
try:
|
||||
if item.stat().st_mtime <= cutoff_ts:
|
||||
item.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
except OSError:
|
||||
logger.exception("Greška pri čišćenju privremene ZIP datoteke %s", item)
|
||||
return deleted
|
||||
|
||||
|
||||
def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month):
|
||||
from modules.task_management.models import Task
|
||||
|
||||
tasks = list(
|
||||
@@ -3028,24 +2988,26 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
||||
|
||||
used_names = set()
|
||||
archive_buffer = BytesIO()
|
||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for task in tasks:
|
||||
work_order = task.work_order
|
||||
if work_order is None:
|
||||
continue
|
||||
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
||||
base_name = _service_records_docx_filename(work_order, task)
|
||||
entry_name = _unique_zip_entry_name(base_name, used_names)
|
||||
archive.writestr(entry_name, docx_bytes)
|
||||
|
||||
archive_content = archive_buffer.getvalue()
|
||||
if not archive_content:
|
||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||
return archive_content
|
||||
tmp_path = _create_tmp_archive_path(prefix='monthly-service-tasks-')
|
||||
try:
|
||||
with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for task in tasks:
|
||||
work_order = task.work_order
|
||||
if work_order is None:
|
||||
continue
|
||||
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
||||
base_name = _service_records_docx_filename(work_order, task)
|
||||
entry_name = _unique_zip_entry_name(base_name, used_names)
|
||||
archive.writestr(entry_name, docx_bytes)
|
||||
if tmp_path.stat().st_size <= 0:
|
||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||
return tmp_path
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
||||
def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month):
|
||||
from modules.task_management.models import Task
|
||||
|
||||
monthly_tasks = (
|
||||
@@ -3084,27 +3046,73 @@ def _build_monthly_work_orders_archive_content(*, user, year, month):
|
||||
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
||||
|
||||
used_names = set()
|
||||
archive_buffer = BytesIO()
|
||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for work_order in work_orders:
|
||||
pdf_bytes = _build_work_order_pdf(work_order)
|
||||
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
||||
archive.writestr(work_order_pdf_name, pdf_bytes)
|
||||
tmp_path = _create_tmp_archive_path(prefix='monthly-work-orders-')
|
||||
try:
|
||||
with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for work_order in work_orders:
|
||||
pdf_bytes = _build_work_order_pdf(work_order)
|
||||
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
||||
archive.writestr(work_order_pdf_name, pdf_bytes)
|
||||
|
||||
display_code = _work_order_display_code(work_order)
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
||||
for index, invoice in enumerate(invoices, start=1):
|
||||
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
|
||||
if not attachment:
|
||||
continue
|
||||
invoice_filename, content, _content_type = attachment
|
||||
archive_path = f"Racuni/{display_code}/{invoice_filename}"
|
||||
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
|
||||
display_code = _work_order_display_code(work_order)
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
||||
for index, invoice in enumerate(invoices, start=1):
|
||||
if not invoice.image:
|
||||
continue
|
||||
invoice_filename = Path(str(getattr(invoice.image, 'name', '') or f"invoice-{index}.bin")).name
|
||||
archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{invoice_filename}", used_names)
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with archive.open(archive_path, mode='w') as dest:
|
||||
while True:
|
||||
chunk = invoice.image.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
dest.write(chunk)
|
||||
finally:
|
||||
invoice.image.close()
|
||||
if tmp_path.stat().st_size <= 0:
|
||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||
return tmp_path
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
archive_content = archive_buffer.getvalue()
|
||||
if not archive_content:
|
||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||
return archive_content
|
||||
|
||||
def _parse_year_month_params(request):
|
||||
from datetime import date as _dt_date
|
||||
|
||||
raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None
|
||||
raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None
|
||||
if raw_year in (None, ''):
|
||||
raw_year = request.query_params.get('year', _dt_date.today().year)
|
||||
if raw_month in (None, ''):
|
||||
raw_month = request.query_params.get('month', _dt_date.today().month)
|
||||
|
||||
try:
|
||||
year = int(raw_year)
|
||||
month = int(raw_month)
|
||||
if not (1 <= month <= 12):
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'})
|
||||
return year, month
|
||||
|
||||
|
||||
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||
tmp_path = _build_monthly_service_tasks_archive_to_temp_file(user=user, year=year, month=month)
|
||||
try:
|
||||
return tmp_path.read_bytes()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
||||
tmp_path = _build_monthly_work_orders_archive_to_temp_file(user=user, year=year, month=month)
|
||||
try:
|
||||
return tmp_path.read_bytes()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _cleanup_expired_generated_archive_records():
|
||||
@@ -3121,6 +3129,7 @@ def _cleanup_expired_generated_archive_records():
|
||||
item.status = 'failed'
|
||||
item.error_message = 'ZIP arhiva je istekla.'
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
_cleanup_stale_tmp_archives()
|
||||
|
||||
|
||||
def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None):
|
||||
@@ -3412,11 +3421,8 @@ def generated_archive_download(request, archive_id):
|
||||
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
|
||||
|
||||
generated_archive.file.open('rb')
|
||||
try:
|
||||
archive_bytes = generated_archive.file.read()
|
||||
finally:
|
||||
if generated_archive.file.size <= 0:
|
||||
generated_archive.file.close()
|
||||
if not archive_bytes:
|
||||
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
||||
request.user,
|
||||
@@ -3424,10 +3430,9 @@ def generated_archive_download(request, archive_id):
|
||||
month=generated_archive.month,
|
||||
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['Cache-Control'] = 'private, max-age=3600'
|
||||
response['Content-Length'] = str(len(archive_bytes))
|
||||
return response
|
||||
|
||||
|
||||
@@ -3706,18 +3711,6 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
).exclude(file='').exclude(file__isnull=True).first()
|
||||
if generated_pdf is None:
|
||||
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))
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||
|
||||
Reference in New Issue
Block a user