Сортировка в таблицах и попытка приструнить генерацию превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-03 01:10:05 +03:00
parent cddbfeadde
commit b76ce4913f
16 changed files with 722 additions and 34 deletions

View File

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