diff --git a/.gitignore b/.gitignore index 8e883cc..5199943 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.py[cod] *$py.class +logs/ # C extensions *.so diff --git a/core/settings.py b/core/settings.py index 3eb273a..30acdda 100644 --- a/core/settings.py +++ b/core/settings.py @@ -25,9 +25,6 @@ env = environ.Env() env_file = os.path.join(BASE_DIR, ".env") if os.path.exists(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') @@ -197,12 +194,4 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 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}") diff --git a/shiftflow/management/commands/dxf_preview_job.py b/shiftflow/management/commands/dxf_preview_job.py index 97e1dea..4259a6f 100644 --- a/shiftflow/management/commands/dxf_preview_job.py +++ b/shiftflow/management/commands/dxf_preview_job.py @@ -1,5 +1,7 @@ +import logging import multiprocessing import os +import sys from django.core.management.base import BaseCommand from django.db import close_old_connections @@ -56,11 +58,25 @@ class Command(BaseCommand): job.started_at = timezone.now() job.finished_at = None job.last_message = "" - job.save(update_fields=["status", "started_at", "finished_at", "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"]) + + 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) 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"] qs = ProductionTask.objects.select_related("deal").filter(deal__status__in=deal_statuses) @@ -79,9 +95,7 @@ class Command(BaseCommand): skipped = 0 errors = 0 - # Таймаут обработки одной детали (сек). - # Если конкретный DXF «залип» — задача не должна блокироваться навсегда. - per_task_timeout = 45 + logger.info('per_task_timeout=%ss', per_task_timeout) try: for task in qs.iterator(chunk_size=50): @@ -106,7 +120,8 @@ class Command(BaseCommand): return # Обрабатываем одну деталь в отдельном процессе и ждём не больше per_task_timeout. - close_old_connections() + # Важно: НЕ вызываем close_old_connections() внутри qs.iterator(), иначе Django может закрыть курсор, + # и итерация по QuerySet упадёт с ошибкой "cursor already closed". q: multiprocessing.Queue = multiprocessing.Queue(maxsize=1) p = multiprocessing.Process(target=_run_one_task_preview, args=(task.id, q)) p.start() @@ -137,8 +152,7 @@ class Command(BaseCommand): skipped += 1 else: errors += 1 - - close_old_connections() + logger.error('error task=%s name=%s deal=%s: %s', task.id, task.drawing_name, task.deal.number, payload) DxfPreviewJob.objects.filter(pk=job_id).update( processed=processed, @@ -153,10 +167,11 @@ class Command(BaseCommand): last_message=f"Готово. Обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.", ) except Exception: + logger.exception('fatal error') DxfPreviewJob.objects.filter(pk=job_id).update( status="failed", finished_at=timezone.now(), - last_message="Задача завершилась с ошибкой (см. логи процесса).", + last_message="Задача завершилась с ошибкой (см. лог на странице обслуживания).", ) finally: close_old_connections() \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/maintenance.html b/shiftflow/templates/shiftflow/maintenance.html index 3ecc70d..ee6d95c 100644 --- a/shiftflow/templates/shiftflow/maintenance.html +++ b/shiftflow/templates/shiftflow/maintenance.html @@ -22,6 +22,9 @@
Обработано: {% if last_job %}{{ last_job.processed }}{% endif %}/{% if last_job %}{{ last_job.total }}{% endif %}
Обновлено: {% if last_job %}{{ last_job.updated }}{% endif %} · Пропущено: {% if last_job %}{{ last_job.skipped }}{% endif %} · Ошибок: {% if last_job %}{{ last_job.errors }}{% endif %}
{% if last_job %}{{ last_job.last_message }}{% endif %}
+
Лог
+
{% if last_job and last_job.log_path %}Файл: {{ last_job.log_path }}{% endif %}
+
{% if last_job %}{{ last_job.log_tail }}{% endif %}
@@ -49,9 +52,12 @@ -
- - +
+ +
@@ -64,6 +70,9 @@ +
@@ -93,6 +102,17 @@ const skippedEl = document.getElementById('jobSkipped'); const errorsEl = document.getElementById('jobErrors'); 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() { try { @@ -109,6 +129,8 @@ if (skippedEl) skippedEl.textContent = String(data.job.skipped ?? ''); if (errorsEl) errorsEl.textContent = String(data.job.errors ?? ''); 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') { setTimeout(tick, 3000); diff --git a/shiftflow/views.py b/shiftflow/views.py index 787de47..8190fc5 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -4,6 +4,9 @@ from urllib.parse import urlsplit import os import subprocess import sys +from pathlib import Path + +from django.conf import settings as django_settings from django.contrib import messages 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() 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.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, keep_original_colors=settings.keep_original_colors, ) - dims = '' + # Временно отключаем вычисление габаритов (bbox), чтобы исключить его влияние на стабильность. + # Превью PNG генерируем как и раньше. + + # Перед сохранением удаляем старое превью, иначе FileSystemStorage добавляет суффиксы + # и папка с превью постепенно «засоряется». try: - dims = _extract_dxf_dimensions(dxf_path) + if task.preview_image: + task.preview_image.delete(save=False) except Exception: - dims = '' + pass filename = f"task_{task.id}_preview.png" task.preview_image.save(filename, ContentFile(png_bytes), save=False) - task.blank_dimensions = dims - task.save(update_fields=['preview_image', 'blank_dimensions']) + task.save(update_fields=['preview_image']) return True # Класс главной страницы (роутер) @@ -644,6 +656,34 @@ def _mark_stale_preview_jobs() -> None: 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): def get(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) @@ -651,12 +691,13 @@ class MaintenanceStatusView(LoginRequiredMixin, View): 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': None, 'log_tail': ''}) + + log_tail = _read_tail(_dxf_job_log_path(job.id)) return JsonResponse({ 'job': { @@ -668,8 +709,10 @@ class MaintenanceStatusView(LoginRequiredMixin, View): 'updated': job.updated, 'skipped': job.skipped, 'errors': job.errors, - 'cancel_requested': job.cancel_requested, + 'cancel_requested': getattr(job, 'cancel_requested', False), '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, 'Остановка запрошена.') 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': messages.success(request, 'Настройки превью сохранены.') return redirect('maintenance') @@ -758,14 +820,27 @@ class MaintenanceView(LoginRequiredMixin, TemplateView): 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 в фоне. Прогресс обновляется ниже.') + 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)], + cwd=str(Path(__file__).resolve().parent.parent), + stdout=log_fh, + stderr=log_fh, + close_fds=True, + ) + finally: + log_fh.close() + + # Если в модели есть поле pid — сохраняем его для диагностики. + try: + job.pid = p.pid + job.save(update_fields=['pid']) + except Exception: + pass + + messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс и лог обновляются ниже.') except Exception: job.status = 'failed' job.last_message = 'Не удалось запустить фоновый процесс генерации превью.'