Добавил превьюшки дхф и настройки сервера
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user