Добавил превьюшки дхф и настройки сервера
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s

This commit is contained in:
2026-04-02 23:52:04 +03:00
parent 9554d47301
commit cddbfeadde
14 changed files with 612 additions and 14 deletions

View File

@@ -1,6 +1,10 @@
from datetime import datetime
from urllib.parse import urlsplit
import os
from django.contrib import messages
from django.core.files.base import ContentFile
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
from django.db.models.functions import Coalesce
from django.http import JsonResponse
@@ -14,7 +18,176 @@ from django.utils import timezone
from warehouse.models import Material, MaterialCategory, SteelGrade
from .forms import ProductionTaskCreateForm
from .models import Company, Deal, Item, Machine, ProductionTask
from .models import Company, Deal, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
def _get_dxf_preview_settings() -> DxfPreviewSettings:
"""Возвращает (и при необходимости создаёт) настройки превью DXF.
Мы храним настройки в БД, чтобы админ мог менять их в интерфейсе.
Ожидаем одну запись (singleton), используем pk=1.
"""
obj, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
return obj
def _render_dxf_preview_png(
dxf_path: str,
*,
line_color: str,
lineweight_scaling: float,
min_lineweight_mm: float,
keep_original_colors: bool,
) -> bytes:
"""Рендерит DXF в PNG (байты) с заданными параметрами.
Зачем это нужно:
- браузер не умеет стабильно показывать DXF как "превью";
- поэтому мы генерируем PNG на сервере и уже её показываем в интерфейсе.
Требуемые зависимости:
- ezdxf
- matplotlib (backend Agg)
Если зависимости не установлены — бросаем исключение с понятным текстом.
"""
try:
# Важно: используем headless-backend, чтобы рендер работал без GUI (на сервере/в Docker).
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
# ezdxf читает DXF и умеет отрисовывать через drawing add-on.
from ezdxf import recover
from ezdxf.addons.drawing import RenderContext, Frontend
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
from ezdxf.addons.drawing import config as draw_config
except Exception as e:
raise RuntimeError('Не установлены зависимости для превью DXF: ezdxf и matplotlib') from e
if not dxf_path or not os.path.exists(dxf_path):
raise FileNotFoundError('DXF файл не найден')
# Безопасное чтение DXF (recover умеет поднимать часть повреждённых файлов).
doc, auditor = recover.readfile(dxf_path)
if auditor and getattr(auditor, 'has_errors', False):
# Даже при ошибках структуры часто удаётся получить картинку — поэтому не прерываем.
pass
# Настройка итогового вида превью:
# - прозрачный фон (чтобы хорошо смотрелось на тёмной теме)
# - цвет/толщина линии задаются настройками (см. «Обслуживание сервера»)
# Конвертируем мм в единицы ezdxf (min_lineweight хранится в 1/300 inch).
# Формула: мм / 25.4 * 300
min_lineweight = int(max(0.0, float(min_lineweight_mm)) / 25.4 * 300)
# Конфигурация рендера: управляем толщиной линий.
cfg = draw_config.Configuration(
lineweight_scaling=float(lineweight_scaling),
min_lineweight=min_lineweight,
)
class PreviewFrontend(Frontend):
"""Переопределяет свойства сущностей перед отрисовкой.
Если keep_original_colors=True — оставляем цвета DXF.
Иначе принудительно красим все линии в заданный line_color.
"""
def override_properties(self, entity, properties) -> None:
if not keep_original_colors:
properties.color = line_color
fig = plt.figure(figsize=(5, 3), dpi=160)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_axis_off()
ax.margins(0)
# Прозрачность фона фигуры и осей.
fig.patch.set_alpha(0)
ax.set_facecolor((0, 0, 0, 0))
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
PreviewFrontend(ctx, out, config=cfg).draw_layout(doc.modelspace(), finalize=True)
import io
buf = io.BytesIO()
fig.savefig(
buf,
format='png',
dpi=160,
bbox_inches='tight',
pad_inches=0.02,
transparent=True,
)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def _extract_dxf_dimensions(dxf_path: str) -> str:
"""Возвращает строку габаритов заготовки вида «300х456 мм».
Используем ezdxf.bbox.extents(), который корректно учитывает дуги/сплайны и вложенные блоки.
Пытаемся учитывать единицы: если в заголовке $INSUNITS указаны дюймы/см/м — конвертируем в мм.
"""
import ezdxf
from ezdxf import bbox as dzbbox
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
# Коэффициент перевода в мм по $INSUNITS
units = int(doc.header.get('$INSUNITS', 0) or 0)
factor = {1: 25.4, 4: 1.0, 5: 10.0, 6: 1000.0}.get(units, 1.0)
extent = dzbbox.extents(msp, cache=dzbbox.Cache())
if not extent.has_data:
return ''
(min_x, min_y, _), (max_x, max_y, _) = extent.min, extent.max
width = (max_x - min_x) * factor
height = (max_y - min_y) * factor
return f"{round(width, 3)}х{round(height, 3)} мм"
def _update_task_preview(task: ProductionTask) -> bool:
"""Обновляет превью PNG и габариты из DXF для одной детали.
Использует текущие настройки из DxfPreviewSettings.
"""
if not task.drawing_file:
return False
name = (task.drawing_file.name or '').lower()
if not name.endswith('.dxf'):
# Если не DXF — превью не делаем и очищаем габариты
task.preview_image = None
task.blank_dimensions = ''
task.save(update_fields=['preview_image', 'blank_dimensions'])
return False
dxf_path = getattr(task.drawing_file, 'path', '')
settings = _get_dxf_preview_settings()
png_bytes = _render_dxf_preview_png(
dxf_path,
line_color=settings.line_color,
lineweight_scaling=settings.lineweight_scaling,
min_lineweight_mm=settings.min_lineweight,
keep_original_colors=settings.keep_original_colors,
)
dims = ''
try:
dims = _extract_dxf_dimensions(dxf_path)
except Exception:
dims = ''
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'])
return True
# Класс главной страницы (роутер)
class IndexView(TemplateView):
@@ -362,6 +535,71 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
return context
class MaintenanceView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/maintenance.html'
def dispatch(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 redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['user_role'] = 'admin'
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
s = _get_dxf_preview_settings()
context['dxf_settings'] = s
return context
def post(self, request, *args, **kwargs):
# На странице обслуживания есть 2 действия:
# 1) сохранить настройки превью
# 2) сохранить настройки и обновить превью по сделкам в статусах lead/work
action = (request.POST.get('action') or '').strip()
# Сохраняем настройки (даже если жмём «Обновить» — чтобы применить их сразу).
s = _get_dxf_preview_settings()
s.line_color = (request.POST.get('line_color') or s.line_color).strip() or s.line_color
try:
s.lineweight_scaling = float(request.POST.get('lineweight_scaling', s.lineweight_scaling))
except ValueError:
pass
try:
s.min_lineweight = float(request.POST.get('min_lineweight', s.min_lineweight))
except ValueError:
pass
s.keep_original_colors = bool(request.POST.get('keep_original_colors'))
s.save()
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)
updated = 0
skipped = 0
errors = 0
for task in tasks:
try:
if _update_task_preview(task):
updated += 1
else:
skipped += 1
except Exception:
errors += 1
messages.success(request, f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.")
return redirect('maintenance')
class CustomersView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/customers.html'
@@ -734,8 +972,41 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
return redirect('registry')
if role in ['admin', 'technologist']:
# Действие формы (обычное сохранение или закрытие позиции)
action = request.POST.get('action', 'save')
# Админ может заменить файлы у детали прямо из карточки пункта сменки.
# Файлы лежат на ProductionTask (основании), а не на Item.
if role == 'admin' and self.object.task_id:
# Админ может заменить файлы детали. После замены:
# - сбрасываем превью;
# - пытаемся сразу извлечь габариты из DXF.
task = self.object.task
drawing_file = request.FILES.get('drawing_file')
extra_drawing = request.FILES.get('extra_drawing')
changed = False
if drawing_file is not None:
task.drawing_file = drawing_file
changed = True
if extra_drawing is not None:
task.extra_drawing = extra_drawing
changed = True
if changed:
task.preview_image = None
# Переcчёт габаритов, если это DXF
dims = ''
try:
if drawing_file is not None and (drawing_file.name or '').lower().endswith('.dxf'):
# временно сохраняем файл на объекте до .save()
pass
path = getattr(task.drawing_file, 'path', '')
if path and path.lower().endswith('.dxf'):
dims = _extract_dxf_dimensions(path)
except Exception:
dims = ''
task.blank_dimensions = dims
task.save()
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)