Сортировка в таблицах и попытка приструнить генерацию превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
This commit is contained in:
@@ -2,8 +2,12 @@ from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import close_old_connections
|
||||
|
||||
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
@@ -18,7 +22,7 @@ from django.utils import timezone
|
||||
from warehouse.models import Material, MaterialCategory, SteelGrade
|
||||
|
||||
from .forms import ProductionTaskCreateForm
|
||||
from .models import Company, Deal, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
|
||||
from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
|
||||
|
||||
|
||||
def _get_dxf_preview_settings() -> DxfPreviewSettings:
|
||||
@@ -156,6 +160,9 @@ def _update_task_preview(task: ProductionTask) -> bool:
|
||||
"""Обновляет превью PNG и габариты из DXF для одной детали.
|
||||
|
||||
Использует текущие настройки из DxfPreviewSettings.
|
||||
|
||||
Важно: функция может выполняться "тяжело" (рендер + bbox), поэтому её удобно
|
||||
вызывать из фонового потока, чтобы не блокировать HTTP-ответ.
|
||||
"""
|
||||
if not task.drawing_file:
|
||||
return False
|
||||
@@ -535,6 +542,138 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
def _run_dxf_preview_job(job_id: int) -> None:
|
||||
"""Выполняет задачу пакетной регенерации превью в фоне.
|
||||
|
||||
Пишем прогресс в DxfPreviewJob, чтобы UI мог показывать результаты.
|
||||
"""
|
||||
try:
|
||||
close_old_connections()
|
||||
job = DxfPreviewJob.objects.get(pk=job_id)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
job.status = 'running'
|
||||
job.started_at = timezone.now()
|
||||
job.last_message = ''
|
||||
job.save(update_fields=['status', 'started_at', 'last_message'])
|
||||
|
||||
# Берём только сделки в статусах «Зашла» и «В работе»
|
||||
deal_statuses = ['lead', 'work']
|
||||
qs = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses)
|
||||
|
||||
try:
|
||||
total = qs.count()
|
||||
except Exception:
|
||||
total = 0
|
||||
|
||||
job.total = total
|
||||
job.processed = 0
|
||||
job.updated = 0
|
||||
job.skipped = 0
|
||||
job.errors = 0
|
||||
job.save(update_fields=['total', 'processed', 'updated', 'skipped', 'errors'])
|
||||
|
||||
# iterator() уменьшает потребление памяти на больших выборках
|
||||
processed = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for task in qs.iterator(chunk_size=50):
|
||||
try:
|
||||
if _update_task_preview(task):
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
processed += 1
|
||||
|
||||
# Обновляем прогресс периодически, чтобы не делать save() на каждую запись
|
||||
if processed % 10 == 0:
|
||||
DxfPreviewJob.objects.filter(pk=job_id).update(
|
||||
processed=processed,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
status = 'done' if errors == 0 else 'done'
|
||||
last_message = f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}."
|
||||
|
||||
DxfPreviewJob.objects.filter(pk=job_id).update(
|
||||
status=status,
|
||||
finished_at=timezone.now(),
|
||||
processed=processed,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
try:
|
||||
close_old_connections()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _mark_stale_preview_jobs() -> None:
|
||||
"""Помечает «залипшие» задачи превью как failed.
|
||||
|
||||
Почему это нужно:
|
||||
- генерация превью запускается в фоне (поток/процесс);
|
||||
- если сервер перезапустили или процесс был убит, job может навсегда остаться в queued/running;
|
||||
- из-за этого UI пишет «уже запущено» и прогресс не двигается.
|
||||
|
||||
Правило:
|
||||
- если job в queued/running и нет finished_at, и он слишком долго не двигается — считаем его умершим.
|
||||
"""
|
||||
now = timezone.now()
|
||||
# Лимит «жизнеспособности» задачи. Можно подстроить.
|
||||
stale_after = timezone.timedelta(minutes=5)
|
||||
|
||||
qs = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True)
|
||||
for job in qs[:10]:
|
||||
# queued без started_at тоже может остаться после рестарта
|
||||
ref_time = job.started_at or job.created_at
|
||||
if ref_time and (now - ref_time) > stale_after:
|
||||
job.status = 'failed'
|
||||
job.finished_at = now
|
||||
job.last_message = 'Задача помечена как зависшая (сервер был перезапущен или процесс остановлен).'
|
||||
job.save(update_fields=['status', 'finished_at', 'last_message'])
|
||||
|
||||
|
||||
class MaintenanceStatusView(LoginRequiredMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
profile = getattr(request.user, 'profile', None)
|
||||
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
|
||||
if role != 'admin':
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
|
||||
# Перед отдачей статуса пытаемся снять «залипшие» задачи.
|
||||
_mark_stale_preview_jobs()
|
||||
|
||||
job = DxfPreviewJob.objects.order_by('-id').first()
|
||||
if not job:
|
||||
return JsonResponse({'job': None})
|
||||
|
||||
return JsonResponse({
|
||||
'job': {
|
||||
'id': job.id,
|
||||
'status': job.status,
|
||||
'status_label': job.get_status_display(),
|
||||
'total': job.total,
|
||||
'processed': job.processed,
|
||||
'updated': job.updated,
|
||||
'skipped': job.skipped,
|
||||
'errors': job.errors,
|
||||
'cancel_requested': job.cancel_requested,
|
||||
'last_message': job.last_message,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class MaintenanceView(LoginRequiredMixin, TemplateView):
|
||||
template_name = 'shiftflow/maintenance.html'
|
||||
|
||||
@@ -553,6 +692,9 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
|
||||
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
|
||||
s = _get_dxf_preview_settings()
|
||||
context['dxf_settings'] = s
|
||||
|
||||
# Последняя фоновая задача (для вывода статуса на странице)
|
||||
context['last_job'] = DxfPreviewJob.objects.order_by('-id').first()
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -573,30 +715,63 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
|
||||
except ValueError:
|
||||
pass
|
||||
s.keep_original_colors = bool(request.POST.get('keep_original_colors'))
|
||||
|
||||
# Таймаут на обработку одной детали (в секундах).
|
||||
# Используется в management-команде, чтобы «плохой» DXF не блокировал всю задачу.
|
||||
try:
|
||||
s.per_task_timeout_sec = int(request.POST.get('per_task_timeout_sec', s.per_task_timeout_sec))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
s.save()
|
||||
|
||||
if action == 'cancel_job':
|
||||
# Мягкая остановка: помечаем текущую задачу флагом cancel_requested.
|
||||
# Воркер завершит работу после текущей детали и поставит статус cancelled.
|
||||
job = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).order_by('-id').first()
|
||||
if not job:
|
||||
messages.info(request, 'Активной задачи нет.')
|
||||
return redirect('maintenance')
|
||||
|
||||
job.cancel_requested = True
|
||||
job.last_message = 'Запрошена остановка. Ожидаем завершения текущей детали.'
|
||||
job.save(update_fields=['cancel_requested', 'last_message'])
|
||||
messages.success(request, 'Остановка запрошена.')
|
||||
return redirect('maintenance')
|
||||
|
||||
if action != 'update_previews':
|
||||
messages.success(request, 'Настройки превью сохранены.')
|
||||
return redirect('maintenance')
|
||||
|
||||
# Обновляем превью только для сделок в статусах «Зашла» и «В работе».
|
||||
deal_statuses = ['lead', 'work']
|
||||
tasks = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses)
|
||||
# Перед проверкой «уже запущено» снимаем залипшие задачи (например после перезапуска сервера).
|
||||
_mark_stale_preview_jobs()
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
# Если уже есть выполняющаяся задача — не запускаем вторую, чтобы не перегружать сервер.
|
||||
running = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).exists()
|
||||
if running:
|
||||
messages.warning(request, 'Обновление уже запущено. Дождись завершения текущей задачи.')
|
||||
return redirect('maintenance')
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
if _update_task_preview(task):
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
# Запускаем регенерацию в отдельном процессе через management-команду.
|
||||
# Причина: рендер DXF и bbox нагружают CPU и могут «тормозить» веб‑процесс из-за GIL,
|
||||
# даже если запускать в потоке.
|
||||
job = DxfPreviewJob.objects.create(status='queued', created_by=request.user)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)],
|
||||
cwd=os.path.dirname(os.path.dirname(__file__)),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс обновляется ниже.')
|
||||
except Exception:
|
||||
job.status = 'failed'
|
||||
job.last_message = 'Не удалось запустить фоновый процесс генерации превью.'
|
||||
job.save(update_fields=['status', 'last_message'])
|
||||
messages.error(request, 'Не удалось запустить обновление превью DXF.')
|
||||
|
||||
messages.success(request, f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.")
|
||||
return redirect('maintenance')
|
||||
|
||||
|
||||
@@ -731,6 +906,23 @@ class ProductionTaskCreateView(LoginRequiredMixin, FormView):
|
||||
|
||||
task.save()
|
||||
|
||||
# Генерация превью/габаритов может занимать время (особенно на больших DXF).
|
||||
# Поэтому запускаем её в фоне и НЕ блокируем сохранение/редирект.
|
||||
def _bg(task_id: int) -> None:
|
||||
try:
|
||||
close_old_connections()
|
||||
t = ProductionTask.objects.get(pk=task_id)
|
||||
_update_task_preview(t)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
close_old_connections()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_bg, args=(task.id,), daemon=True).start()
|
||||
|
||||
next_url = (self.request.POST.get('next') or '').strip()
|
||||
if next_url.startswith('/'):
|
||||
return redirect(next_url)
|
||||
|
||||
Reference in New Issue
Block a user