Пытаемся угомонить превьюшки
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s

This commit is contained in:
2026-04-03 01:58:23 +03:00
parent b76ce4913f
commit 1fe05d41f6
5 changed files with 141 additions and 39 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
logs/
# C extensions # C extensions
*.so *.so

View File

@@ -25,9 +25,6 @@ env = environ.Env()
env_file = os.path.join(BASE_DIR, ".env") env_file = os.path.join(BASE_DIR, ".env")
if os.path.exists(env_file): if os.path.exists(env_file):
environ.Env.read_env(env_file) environ.Env.read_env(env_file)
print(f"Файл .env найден и прочитан: {env_file}")
else:
print(f"ОШИБКА: Файл .env не найден по пути: {env_file}")
# читаем переменную окружения # читаем переменную окружения
ENV_TYPE = os.getenv('ENV_TYPE', 'local') ENV_TYPE = os.getenv('ENV_TYPE', 'local')
@@ -197,12 +194,4 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
CSRF_TRUSTED_ORIGINS = env.list('CSRF_ORIGINS', default=['http://localhost']) CSRF_TRUSTED_ORIGINS = env.list('CSRF_ORIGINS', default=['http://localhost'])
print(f"--- РАБОТАЕМ НА БАЗЕ: {DATABASES['default']['NAME']} (HOST: {DATABASES['default'].get('HOST', 'localhost')}) ---")
# Проверяем, видит ли он базу и режим отладки
print(f"DB_NAME: {env('DB_NAME', default='НЕ НАЙДЕНО')}")
print(f"ENV_TYPE: {env('ENV_TYPE', default='False')}")
print(f"SECRET_KEY: {env('SECRET_KEY', default='False')}")
print(f"CSRF_TRUSTED_ORIGINS: {CSRF_TRUSTED_ORIGINS}")

View File

@@ -1,5 +1,7 @@
import logging
import multiprocessing import multiprocessing
import os import os
import sys
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import close_old_connections from django.db import close_old_connections
@@ -56,11 +58,25 @@ class Command(BaseCommand):
job.started_at = timezone.now() job.started_at = timezone.now()
job.finished_at = None job.finished_at = None
job.last_message = "" job.last_message = ""
try:
job.pid = os.getpid()
job.save(update_fields=["status", "started_at", "finished_at", "last_message", "pid"])
except Exception:
job.save(update_fields=["status", "started_at", "finished_at", "last_message"]) job.save(update_fields=["status", "started_at", "finished_at", "last_message"])
logger = logging.getLogger('dxf_preview_job')
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s', '%Y-%m-%d %H:%M:%S'))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info('start job=%s pid=%s', job_id, os.getpid())
# Берём настройки таймаута из БД. # Берём настройки таймаута из БД.
settings, _ = DxfPreviewSettings.objects.get_or_create(pk=1) settings, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
per_task_timeout = int(getattr(settings, 'per_task_timeout_sec', 45) or 45) per_task_timeout = int(getattr(settings, 'per_task_timeout_sec', 45) or 45)
per_task_timeout = max(10, min(50, per_task_timeout))
deal_statuses = ["lead", "work"] deal_statuses = ["lead", "work"]
qs = ProductionTask.objects.select_related("deal").filter(deal__status__in=deal_statuses) qs = ProductionTask.objects.select_related("deal").filter(deal__status__in=deal_statuses)
@@ -79,9 +95,7 @@ class Command(BaseCommand):
skipped = 0 skipped = 0
errors = 0 errors = 0
# Таймаут обработки одной детали (сек). logger.info('per_task_timeout=%ss', per_task_timeout)
# Если конкретный DXF «залип» — задача не должна блокироваться навсегда.
per_task_timeout = 45
try: try:
for task in qs.iterator(chunk_size=50): for task in qs.iterator(chunk_size=50):
@@ -106,7 +120,8 @@ class Command(BaseCommand):
return return
# Обрабатываем одну деталь в отдельном процессе и ждём не больше per_task_timeout. # Обрабатываем одну деталь в отдельном процессе и ждём не больше per_task_timeout.
close_old_connections() # Важно: НЕ вызываем close_old_connections() внутри qs.iterator(), иначе Django может закрыть курсор,
# и итерация по QuerySet упадёт с ошибкой "cursor already closed".
q: multiprocessing.Queue = multiprocessing.Queue(maxsize=1) q: multiprocessing.Queue = multiprocessing.Queue(maxsize=1)
p = multiprocessing.Process(target=_run_one_task_preview, args=(task.id, q)) p = multiprocessing.Process(target=_run_one_task_preview, args=(task.id, q))
p.start() p.start()
@@ -137,8 +152,7 @@ class Command(BaseCommand):
skipped += 1 skipped += 1
else: else:
errors += 1 errors += 1
logger.error('error task=%s name=%s deal=%s: %s', task.id, task.drawing_name, task.deal.number, payload)
close_old_connections()
DxfPreviewJob.objects.filter(pk=job_id).update( DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed, processed=processed,
@@ -153,10 +167,11 @@ class Command(BaseCommand):
last_message=f"Готово. Обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.", last_message=f"Готово. Обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.",
) )
except Exception: except Exception:
logger.exception('fatal error')
DxfPreviewJob.objects.filter(pk=job_id).update( DxfPreviewJob.objects.filter(pk=job_id).update(
status="failed", status="failed",
finished_at=timezone.now(), finished_at=timezone.now(),
last_message="Задача завершилась с ошибкой (см. логи процесса).", last_message="Задача завершилась с ошибкой (см. лог на странице обслуживания).",
) )
finally: finally:
close_old_connections() close_old_connections()

View File

@@ -22,6 +22,9 @@
<div class="small text-muted">Обработано: <span id="jobProcessed">{% if last_job %}{{ last_job.processed }}{% endif %}</span>/<span id="jobTotal">{% if last_job %}{{ last_job.total }}{% endif %}</span></div> <div class="small text-muted">Обработано: <span id="jobProcessed">{% if last_job %}{{ last_job.processed }}{% endif %}</span>/<span id="jobTotal">{% if last_job %}{{ last_job.total }}{% endif %}</span></div>
<div class="small text-muted">Обновлено: <span id="jobUpdated">{% if last_job %}{{ last_job.updated }}{% endif %}</span> · Пропущено: <span id="jobSkipped">{% if last_job %}{{ last_job.skipped }}{% endif %}</span> · Ошибок: <span id="jobErrors">{% if last_job %}{{ last_job.errors }}{% endif %}</span></div> <div class="small text-muted">Обновлено: <span id="jobUpdated">{% if last_job %}{{ last_job.updated }}{% endif %}</span> · Пропущено: <span id="jobSkipped">{% if last_job %}{{ last_job.skipped }}{% endif %}</span> · Ошибок: <span id="jobErrors">{% if last_job %}{{ last_job.errors }}{% endif %}</span></div>
<div class="small text-muted" id="jobMessage">{% if last_job %}{{ last_job.last_message }}{% endif %}</div> <div class="small text-muted" id="jobMessage">{% if last_job %}{{ last_job.last_message }}{% endif %}</div>
<div class="small text-muted mt-2">Лог</div>
<div class="small text-muted" id="jobLogPath">{% if last_job and last_job.log_path %}Файл: {{ last_job.log_path }}{% endif %}</div>
<pre id="jobLog" class="border border-secondary rounded p-2 mb-0" style="max-height: 220px; overflow:auto; white-space: pre-wrap;">{% if last_job %}{{ last_job.log_tail }}{% endif %}</pre>
</div> </div>
<form method="post" class="row g-3 align-items-end"> <form method="post" class="row g-3 align-items-end">
@@ -49,9 +52,12 @@
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-6">
<label class="small text-muted">Таймаут на 1 DXF (сек)</label> <label class="small text-muted d-flex justify-content-between">
<input type="number" step="1" min="5" name="per_task_timeout_sec" class="form-control border-secondary" value="{{ dxf_settings.per_task_timeout_sec|default_if_none:45|unlocalize }}"> <span>Таймаут на 1 DXF (сек)</span>
<span id="timeoutValue">{{ dxf_settings.per_task_timeout_sec|default_if_none:45|unlocalize }}</span>
</label>
<input type="range" min="10" max="50" step="1" name="per_task_timeout_sec" id="timeoutRange" class="form-range" value="{{ dxf_settings.per_task_timeout_sec|default_if_none:45|unlocalize }}">
</div> </div>
<div class="col-12 d-flex gap-2"> <div class="col-12 d-flex gap-2">
@@ -64,6 +70,9 @@
<button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job"> <button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job">
<i class="bi bi-stop-circle me-2"></i>Прервать <i class="bi bi-stop-circle me-2"></i>Прервать
</button> </button>
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_log">
<i class="bi bi-eraser me-2"></i>Очистить лог
</button>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -93,6 +102,17 @@
const skippedEl = document.getElementById('jobSkipped'); const skippedEl = document.getElementById('jobSkipped');
const errorsEl = document.getElementById('jobErrors'); const errorsEl = document.getElementById('jobErrors');
const msgEl = document.getElementById('jobMessage'); const msgEl = document.getElementById('jobMessage');
const logEl = document.getElementById('jobLog');
const logPathEl = document.getElementById('jobLogPath');
const timeoutRange = document.getElementById('timeoutRange');
const timeoutValue = document.getElementById('timeoutValue');
if (timeoutRange && timeoutValue) {
timeoutValue.textContent = timeoutRange.value;
timeoutRange.addEventListener('input', function () {
timeoutValue.textContent = timeoutRange.value;
});
}
async function tick() { async function tick() {
try { try {
@@ -109,6 +129,8 @@
if (skippedEl) skippedEl.textContent = String(data.job.skipped ?? ''); if (skippedEl) skippedEl.textContent = String(data.job.skipped ?? '');
if (errorsEl) errorsEl.textContent = String(data.job.errors ?? ''); if (errorsEl) errorsEl.textContent = String(data.job.errors ?? '');
if (msgEl) msgEl.textContent = data.job.last_message || ''; if (msgEl) msgEl.textContent = data.job.last_message || '';
if (logEl) logEl.textContent = data.job.log_tail || '';
if (logPathEl) logPathEl.textContent = data.job.log_path ? `Файл: ${data.job.log_path}` : '';
if (data.job.status === 'running' || data.job.status === 'queued') { if (data.job.status === 'running' || data.job.status === 'queued') {
setTimeout(tick, 3000); setTimeout(tick, 3000);

View File

@@ -4,6 +4,9 @@ from urllib.parse import urlsplit
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
from django.conf import settings as django_settings
from django.contrib import messages from django.contrib import messages
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -169,7 +172,12 @@ def _update_task_preview(task: ProductionTask) -> bool:
name = (task.drawing_file.name or '').lower() name = (task.drawing_file.name or '').lower()
if not name.endswith('.dxf'): if not name.endswith('.dxf'):
# Если не DXF — превью не делаем и очищаем габариты # Если не DXF — превью не делаем: удаляем старый PNG (если был) и очищаем габариты.
try:
if task.preview_image:
task.preview_image.delete(save=False)
except Exception:
pass
task.preview_image = None task.preview_image = None
task.blank_dimensions = '' task.blank_dimensions = ''
task.save(update_fields=['preview_image', 'blank_dimensions']) task.save(update_fields=['preview_image', 'blank_dimensions'])
@@ -184,16 +192,20 @@ def _update_task_preview(task: ProductionTask) -> bool:
min_lineweight_mm=settings.min_lineweight, min_lineweight_mm=settings.min_lineweight,
keep_original_colors=settings.keep_original_colors, keep_original_colors=settings.keep_original_colors,
) )
dims = '' # Временно отключаем вычисление габаритов (bbox), чтобы исключить его влияние на стабильность.
# Превью PNG генерируем как и раньше.
# Перед сохранением удаляем старое превью, иначе FileSystemStorage добавляет суффиксы
# и папка с превью постепенно «засоряется».
try: try:
dims = _extract_dxf_dimensions(dxf_path) if task.preview_image:
task.preview_image.delete(save=False)
except Exception: except Exception:
dims = '' pass
filename = f"task_{task.id}_preview.png" filename = f"task_{task.id}_preview.png"
task.preview_image.save(filename, ContentFile(png_bytes), save=False) task.preview_image.save(filename, ContentFile(png_bytes), save=False)
task.blank_dimensions = dims task.save(update_fields=['preview_image'])
task.save(update_fields=['preview_image', 'blank_dimensions'])
return True return True
# Класс главной страницы (роутер) # Класс главной страницы (роутер)
@@ -644,6 +656,34 @@ def _mark_stale_preview_jobs() -> None:
job.save(update_fields=['status', 'finished_at', 'last_message']) job.save(update_fields=['status', 'finished_at', 'last_message'])
def _dxf_job_log_path(job_id: int) -> Path:
"""Путь к лог-файлу фоновой задачи DXF превью."""
base_dir = Path(getattr(django_settings, 'BASE_DIR', Path(__file__).resolve().parent.parent))
logs_dir = base_dir / 'logs'
logs_dir.mkdir(parents=True, exist_ok=True)
return logs_dir / f'dxf_preview_job_{job_id}.log'
def _read_tail(path: Path, max_bytes: int = 32_000) -> str:
"""Читает «хвост» файла (последние max_bytes) для вывода в UI."""
try:
if not path.exists():
return ''
with path.open('rb') as f:
f.seek(0, os.SEEK_END)
size = f.tell()
start = max(0, size - max_bytes)
f.seek(start)
data = f.read()
if start > 0:
nl = data.find(b'\n')
if nl != -1:
data = data[nl + 1 :]
return data.decode('utf-8', errors='replace')
except Exception:
return ''
class MaintenanceStatusView(LoginRequiredMixin, View): class MaintenanceStatusView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
@@ -651,12 +691,13 @@ class MaintenanceStatusView(LoginRequiredMixin, View):
if role != 'admin': if role != 'admin':
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
# Перед отдачей статуса пытаемся снять «залипшие» задачи.
_mark_stale_preview_jobs() _mark_stale_preview_jobs()
job = DxfPreviewJob.objects.order_by('-id').first() job = DxfPreviewJob.objects.order_by('-id').first()
if not job: if not job:
return JsonResponse({'job': None}) return JsonResponse({'job': None, 'log_tail': ''})
log_tail = _read_tail(_dxf_job_log_path(job.id))
return JsonResponse({ return JsonResponse({
'job': { 'job': {
@@ -668,8 +709,10 @@ class MaintenanceStatusView(LoginRequiredMixin, View):
'updated': job.updated, 'updated': job.updated,
'skipped': job.skipped, 'skipped': job.skipped,
'errors': job.errors, 'errors': job.errors,
'cancel_requested': job.cancel_requested, 'cancel_requested': getattr(job, 'cancel_requested', False),
'last_message': job.last_message, 'last_message': job.last_message,
'log_tail': log_tail,
'log_path': str(_dxf_job_log_path(job.id)),
} }
}) })
@@ -739,6 +782,25 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
messages.success(request, 'Остановка запрошена.') messages.success(request, 'Остановка запрошена.')
return redirect('maintenance') return redirect('maintenance')
if action == 'clear_log':
# Очистка лог-файла последней задачи. Во время выполнения не трогаем,
# потому что процесс может держать открытый дескриптор файла.
job = DxfPreviewJob.objects.order_by('-id').first()
if not job:
messages.info(request, 'Логов нет.')
return redirect('maintenance')
if job.status in ['queued', 'running'] and not job.finished_at:
messages.warning(request, 'Нельзя очистить лог во время выполнения задачи.')
return redirect('maintenance')
try:
_dxf_job_log_path(job.id).open('wb').close()
messages.success(request, 'Лог очищен.')
except Exception:
messages.error(request, 'Не удалось очистить лог.')
return redirect('maintenance')
if action != 'update_previews': if action != 'update_previews':
messages.success(request, 'Настройки превью сохранены.') messages.success(request, 'Настройки превью сохранены.')
return redirect('maintenance') return redirect('maintenance')
@@ -758,14 +820,27 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
job = DxfPreviewJob.objects.create(status='queued', created_by=request.user) job = DxfPreviewJob.objects.create(status='queued', created_by=request.user)
try: try:
subprocess.Popen( log_path = _dxf_job_log_path(job.id)
log_fh = log_path.open('ab')
try:
p = subprocess.Popen(
[sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)], [sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)],
cwd=os.path.dirname(os.path.dirname(__file__)), cwd=str(Path(__file__).resolve().parent.parent),
stdout=subprocess.DEVNULL, stdout=log_fh,
stderr=subprocess.DEVNULL, stderr=log_fh,
close_fds=True, close_fds=True,
) )
messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс обновляется ниже.') finally:
log_fh.close()
# Если в модели есть поле pid — сохраняем его для диагностики.
try:
job.pid = p.pid
job.save(update_fields=['pid'])
except Exception:
pass
messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс и лог обновляются ниже.')
except Exception: except Exception:
job.status = 'failed' job.status = 'failed'
job.last_message = 'Не удалось запустить фоновый процесс генерации превью.' job.last_message = 'Не удалось запустить фоновый процесс генерации превью.'