Добавил превьюшки дхф и настройки сервера
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

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Django: Runserver",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"runserver"
],
"django": true,
"justMyCode": true,
// Это заставит сервер перезапускаться при изменении кода
"autoReload": {
"enable": true
}
}
]
}

42
.vscode/settings.json vendored
View File

@@ -1,3 +1,43 @@
{ {
"python-envs.pythonProjects": [] // --- ПИТОН И АВТОМАТИКА ---
"python.analysis.typeCheckingMode": "basic", // Подсказки по типам данных
"editor.formatOnSave": true, // Форматировать код при сохранении (маст-хэв!)
"python.formatting.provider": "black", // Использовать Black для красоты кода
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit" // Сам расставит импорты по алфавиту
},
// --- DJANGO И HTML ---
"files.associations": {
"**/*.html": "django-html", // Чтобы VS Code понимал синтаксис {% if %}
"**/templates/**/*.html": "django-html"
},
"emmet.includeLanguages": {
"django-html": "html" // Чтобы Emmet (развертывание тегов через Tab) работал в шаблонах
},
// --- ИНТЕРФЕЙС И КОМФОРТ ---
"editor.bracketPairColorization.enabled": true, // Цветные скобочки (чтобы не путаться в вложенности)
"editor.guide.bracketPairs": "active",
"editor.fontSize": 14, // Подбери под свои глаза
"editor.tabSize": 4,
"editor.renderWhitespace": "boundary", // Видеть лишние пробелы в конце строк
"files.autoSave": "onFocusChange", // Сохранять файл, когда переключаешься в браузер
// --- ЧИСТОТА В ПРОЕКТЕ ---
"files.exclude": {
"**/.git": true,
"**/__pycache__": true, // Прячем мусорные папки питона
"**/*.pyc": true,
"**/node_modules": true,
"**/.DS_Store": true
},
// --- ТЕРМИНАЛ ---
"terminal.integrated.fontSize": 13,
"terminal.integrated.cursorStyle": "line",
// --- ГИТ ---
"git.autofetch": true, // Проверять обновления в репозитории самостоятльно
"git.confirmSync": false
} }

Binary file not shown.

View File

@@ -27,3 +27,25 @@ class ProductionTaskCreateForm(forms.Form):
required=True, required=True,
empty_label="— выбрать —", empty_label="— выбрать —",
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
existing = self.fields["drawing_name"].widget.attrs.get("class", "")
self.fields["drawing_name"].widget.attrs["class"] = (existing + " w-100").strip()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Приводим поля формы к единому Bootstrap-оформлению.
# Это решает проблему, когда input «Наименование» выглядит как стандартный HTML и не занимает всю ширину.
for name, field in self.fields.items():
w = field.widget
if isinstance(w, forms.CheckboxInput):
w.attrs.setdefault('class', 'form-check-input')
elif isinstance(w, (forms.Select, forms.SelectMultiple)):
w.attrs.setdefault('class', 'form-select border-secondary')
else:
w.attrs.setdefault('class', 'form-control border-secondary')
# Явно делаем поле «Наименование детали» растягиваемым на всю ширину.
self.fields['drawing_name'].widget.attrs['class'] += ' w-100'

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-02 19:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0009_deal_status'),
]
operations = [
migrations.AddField(
model_name='productiontask',
name='preview_image',
field=models.ImageField(blank=True, null=True, upload_to='task_previews/%Y/%m/', verbose_name='Превью DXF (PNG)'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-02 20:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0010_productiontask_preview_image'),
]
operations = [
migrations.AddField(
model_name='productiontask',
name='blank_dimensions',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='Габариты заготовки'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.3 on 2026-04-02 20:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0011_productiontask_blank_dimensions'),
]
operations = [
migrations.CreateModel(
name='DxfPreviewSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('line_color', models.CharField(default='#006400', help_text='Напр: #006400 (тёмно-зелёный)', max_length=16, verbose_name='Цвет линий превью (HEX)')),
('lineweight_scaling', models.FloatField(default=1.0, help_text='1.0 = как в DXF, 2.0 = толще, 0.5 = тоньше', verbose_name='Коэффициент толщины линий')),
('min_lineweight', models.FloatField(default=0.1, help_text='Если в DXF нет lineweight — используем минимум, чтобы линии были видимы', verbose_name='Минимальная толщина (мм)')),
('keep_original_colors', models.BooleanField(default=False, help_text='Если включено — не перекрашиваем линии, берём цвета из DXF', verbose_name='Оставить цвета оригинальные')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
],
options={
'verbose_name': 'Настройки превью DXF',
'verbose_name_plural': 'Настройки превью DXF',
},
),
]

View File

@@ -70,6 +70,8 @@ class ProductionTask(models.Model):
drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True) drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True)
extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True) extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True)
preview_image = models.ImageField("Превью DXF (PNG)", upload_to="task_previews/%Y/%m/", blank=True, null=True)
blank_dimensions = models.CharField("Габариты заготовки", max_length=64, blank=True, default="")
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал") material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал")
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт") quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
@@ -84,6 +86,47 @@ class ProductionTask(models.Model):
def __str__(self): def __str__(self):
return f"{self.drawing_name} (Заказ {self.deal.number})" return f"{self.drawing_name} (Заказ {self.deal.number})"
class DxfPreviewSettings(models.Model):
"""Настройки генерации превью для DXF.
Храним в БД, чтобы админ мог менять параметры через страницу «Обслуживание сервера»
без правок кода.
Сделано как singleton: ожидается одна строка (обычно pk=1).
"""
line_color = models.CharField(
"Цвет линий превью (HEX)",
max_length=16,
default="#006400",
help_text="Напр: #006400 (тёмно-зелёный)",
)
lineweight_scaling = models.FloatField(
"Коэффициент толщины линий",
default=1.0,
help_text="1.0 = как в DXF, 2.0 = толще, 0.5 = тоньше",
)
min_lineweight = models.FloatField(
"Минимальная толщина (мм)",
default=0.1,
help_text="Если в DXF нет lineweight — используем минимум, чтобы линии были видимы",
)
keep_original_colors = models.BooleanField(
"Оставить цвета оригинальные",
default=False,
help_text="Если включено — не перекрашиваем линии, берём цвета из DXF",
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки превью DXF"
verbose_name_plural = "Настройки превью DXF"
def __str__(self):
return "Настройки превью DXF"
class Item(models.Model): class Item(models.Model):
""" """
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал. Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.

View File

@@ -25,7 +25,7 @@
{% endif %} {% endif %}
</div> </div>
<form method="post" id="mainForm" class="card-body p-4"> <form method="post" enctype="multipart/form-data" id="mainForm" class="card-body p-4">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ back_url }}"> <input type="hidden" name="next" value="{{ back_url }}">
@@ -52,7 +52,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<small class="text-muted d-block">Материал</small> <small class="text-muted d-block">Материал</small>
<strong>{{ item.task.material.name }}</strong> <strong>{{ item.task.material.full_name|default:item.task.material.name }}</strong>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<small class="text-muted d-block">План</small> <small class="text-muted d-block">План</small>
@@ -60,9 +60,67 @@
</div> </div>
</div> </div>
<div class="mb-4 d-flex gap-2"> <div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
{% if item.task.drawing_file %}<a href="{{ item.task.drawing_file.url }}" target="_blank" class="btn btn-outline-info btn-sm">DXF</a>{% endif %} <div class="col-md-6">
{% if item.task.extra_drawing %}<a href="{{ item.task.extra_drawing.url }}" target="_blank" class="btn btn-outline-danger btn-sm">PDF</a>{% endif %} <small class="text-muted d-block">Размер детали</small>
<strong>{{ item.task.size_value|default:"-" }}</strong>
</div>
<div class="col-md-6">
<small class="text-muted d-block">Габариты заготовки</small>
<strong>{{ item.task.blank_dimensions|default:"—" }}</strong>
</div>
</div>
<div class="mb-4">
<div class="small text-muted mb-2">Превью</div>
<div class="row g-3">
<div class="col-md-8">
<div class="border border-secondary rounded p-2" style="height: 200px; overflow: hidden;">
{% if item.task.preview_image %}
<img src="{{ item.task.preview_image.url }}" alt="Превью DXF" style="max-width:100%; max-height:100%; object-fit:contain; display:block; margin:0 auto;">
{% else %}
<div style="width:100%; height:100%;"></div>
{% endif %}
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
{% if item.task.drawing_file %}
<a href="{{ item.task.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% else %}
<span class="btn btn-sm btn-outline-secondary p-1 disabled" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</span>
{% endif %}
<div class="small text-muted">DXF/STEP</div>
</div>
{% if user_role == 'admin' %}
<input type="file" name="drawing_file" class="form-control border-secondary" accept=".dxf,.iges,.igs,.step,.stp">
{% endif %}
</div>
<div>
<div class="d-flex align-items-center gap-2 mb-2">
{% if item.task.extra_drawing %}
<a href="{{ item.task.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1" title="PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% else %}
<span class="btn btn-sm btn-outline-secondary p-1 disabled" title="PDF">
<i class="bi bi-file-pdf"></i>
</span>
{% endif %}
<div class="small text-muted">PDF</div>
</div>
{% if user_role == 'admin' %}
<input type="file" name="extra_drawing" class="form-control border-secondary" accept="application/pdf">
{% endif %}
</div>
</div>
</div>
</div> </div>
<input type="hidden" name="status" id="id_status" value="{{ item.status }}"> <input type="hidden" name="status" id="id_status" value="{{ item.status }}">

View File

@@ -0,0 +1,72 @@
{% extends 'base.html' %}
{% load l10n %}
{% block content %}
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-tools me-2"></i>Обслуживание сервера</h3>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере.
</div>
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2">
<strong>DXF</strong>
</div>
<div class="card-body">
<form method="post" class="row g-3 align-items-end">
{% csrf_token %}
<div class="col-md-3">
<label class="small text-muted">Цвет превью</label>
<input type="color" name="line_color" class="form-control form-control-color border-secondary" value="{{ dxf_settings.line_color|default:'#006400'|slice:':7' }}">
</div>
<div class="col-md-3">
<label class="small text-muted">Толщина линии (коэфф.)</label>
<input type="number" step="0.1" min="0.1" name="lineweight_scaling" class="form-control border-secondary" value="{{ dxf_settings.lineweight_scaling|default_if_none:1.0|unlocalize }}">
</div>
<div class="col-md-3">
<label class="small text-muted">Мин. толщина (мм)</label>
<input type="number" step="0.05" min="0" name="min_lineweight" class="form-control border-secondary" value="{{ dxf_settings.min_lineweight|default_if_none:0.1|unlocalize }}">
</div>
<div class="col-md-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keepColors" name="keep_original_colors" {% if dxf_settings.keep_original_colors %}checked{% endif %}>
<label class="form-check-label" for="keepColors">Оставить оригинальные цвета</label>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-outline-accent" name="action" value="save_settings">
<i class="bi bi-save me-2"></i>Сохранить настройки
</button>
<button type="submit" class="btn btn-outline-accent" name="action" value="update_previews">
<i class="bi bi-arrow-repeat me-2"></i>Обновить превьюшки DXF
</button>
</div>
<div class="col-12">
<div class="text-muted small">
Пакетное обновление пробегает по сделкам в статусах «Зашла» и «В работе».
</div>
</div>
</form>
</div>
</div>
{% if messages %}
<div class="mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -35,7 +35,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-8"> <div class="col-12">
<label class="form-label small text-muted">{{ form.drawing_name.label }}</label> <label class="form-label small text-muted">{{ form.drawing_name.label }}</label>
{{ form.drawing_name }} {{ form.drawing_name }}
{% for e in form.drawing_name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %} {% for e in form.drawing_name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
@@ -50,7 +50,7 @@
{{ form.size_value }} {{ form.size_value }}
{% for e in form.size_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %} {% for e in form.size_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div> </div>
<div class="col-md-8 d-flex align-items-end"> <div class="col-md-4 d-flex align-items-end">
<div class="form-check form-switch"> <div class="form-check form-switch">
{{ form.is_bend }} {{ form.is_bend }}
<label class="form-check-label ms-2">{{ form.is_bend.label }}</label> <label class="form-check-label ms-2">{{ form.is_bend.label }}</label>

View File

@@ -8,6 +8,7 @@ from .views import (
DealUpsertView, DealUpsertView,
IndexView, IndexView,
ItemUpdateView, ItemUpdateView,
MaintenanceView,
MaterialCategoryUpsertView, MaterialCategoryUpsertView,
MaterialDetailView, MaterialDetailView,
MaterialUpsertView, MaterialUpsertView,
@@ -32,6 +33,7 @@ urlpatterns = [
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'), path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'), path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'), path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
path('maintenance/', MaintenanceView.as_view(), name='maintenance'),
path('planning/add/', PlanningAddView.as_view(), name='planning_add'), path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'), path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'), path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),

View File

@@ -1,6 +1,10 @@
from datetime import datetime from datetime import datetime
from urllib.parse import urlsplit 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 import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import JsonResponse from django.http import JsonResponse
@@ -14,7 +18,176 @@ from django.utils import timezone
from warehouse.models import Material, MaterialCategory, SteelGrade from warehouse.models import Material, MaterialCategory, SteelGrade
from .forms import ProductionTaskCreateForm 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): class IndexView(TemplateView):
@@ -362,6 +535,71 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
return context 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): class CustomersView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/customers.html' template_name = 'shiftflow/customers.html'
@@ -734,8 +972,41 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
return redirect('registry') return redirect('registry')
if role in ['admin', 'technologist']: if role in ['admin', 'technologist']:
# Действие формы (обычное сохранение или закрытие позиции)
action = request.POST.get('action', 'save') 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') machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit(): if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id) self.object.machine_id = int(machine_id)

View File

@@ -31,6 +31,12 @@
{% if user_role in 'admin,technologist,clerk' %} {% if user_role in 'admin,technologist,clerk' %}
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li> <li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
{% endif %} {% endif %}
{% if user_role == 'admin' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'maintenance' %}active{% endif %}" href="{% url 'maintenance' %}">Обслуживание сервера</a>
</li>
{% endif %}
</ul> </ul>
<div class="d-flex align-items-center gap-2 mt-lg-0 mt-3"> <div class="d-flex align-items-center gap-2 mt-lg-0 mt-3">