diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..642259b --- /dev/null +++ b/.vscode/launch.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e68766..20fa176 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3128fff..5c9eb9d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/shiftflow/forms.py b/shiftflow/forms.py index 851d6cd..aed8d7d 100644 --- a/shiftflow/forms.py +++ b/shiftflow/forms.py @@ -26,4 +26,26 @@ class ProductionTaskCreateForm(forms.Form): queryset=Material.objects.all().order_by("full_name"), required=True, empty_label="— выбрать —", - ) \ No newline at end of file + ) + + 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' \ No newline at end of file diff --git a/shiftflow/migrations/0010_productiontask_preview_image.py b/shiftflow/migrations/0010_productiontask_preview_image.py new file mode 100644 index 0000000..fe09ba5 --- /dev/null +++ b/shiftflow/migrations/0010_productiontask_preview_image.py @@ -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)'), + ), + ] diff --git a/shiftflow/migrations/0011_productiontask_blank_dimensions.py b/shiftflow/migrations/0011_productiontask_blank_dimensions.py new file mode 100644 index 0000000..9c6d0e3 --- /dev/null +++ b/shiftflow/migrations/0011_productiontask_blank_dimensions.py @@ -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='Габариты заготовки'), + ), + ] diff --git a/shiftflow/migrations/0012_dxfpreviewsettings.py b/shiftflow/migrations/0012_dxfpreviewsettings.py new file mode 100644 index 0000000..aaa1fbb --- /dev/null +++ b/shiftflow/migrations/0012_dxfpreviewsettings.py @@ -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', + }, + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index 72ee6cf..30ad510 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -64,13 +64,15 @@ class ProductionTask(models.Model): Создается технологом или мастером на основе заказа. """ deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") - + drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч") size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)") - + 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) - + 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="Материал") quantity_ordered = models.PositiveIntegerField("Заказано всего, шт") is_bend = models.BooleanField("Гибка", default=False) @@ -84,6 +86,47 @@ class ProductionTask(models.Model): def __str__(self): 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): """ Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал. diff --git a/shiftflow/templates/shiftflow/item_detail.html b/shiftflow/templates/shiftflow/item_detail.html index bb1d5d6..43c744f 100644 --- a/shiftflow/templates/shiftflow/item_detail.html +++ b/shiftflow/templates/shiftflow/item_detail.html @@ -25,7 +25,7 @@ {% endif %} -
+ {% csrf_token %} @@ -52,7 +52,7 @@
Материал - {{ item.task.material.name }} + {{ item.task.material.full_name|default:item.task.material.name }}
План @@ -60,9 +60,67 @@
-
- {% if item.task.drawing_file %}DXF{% endif %} - {% if item.task.extra_drawing %}PDF{% endif %} +
+
+ Размер детали + {{ item.task.size_value|default:"-" }} +
+
+ Габариты заготовки + {{ item.task.blank_dimensions|default:"—" }} +
+
+ +
+
Превью
+
+
+
+ {% if item.task.preview_image %} + Превью DXF + {% else %} +
+ {% endif %} +
+
+
+
+
+ {% if item.task.drawing_file %} + + + + {% else %} + + + + {% endif %} +
DXF/STEP
+
+ {% if user_role == 'admin' %} + + {% endif %} +
+ +
+
+ {% if item.task.extra_drawing %} + + + + {% else %} + + + + {% endif %} +
PDF
+
+ {% if user_role == 'admin' %} + + {% endif %} +
+
+
diff --git a/shiftflow/templates/shiftflow/maintenance.html b/shiftflow/templates/shiftflow/maintenance.html new file mode 100644 index 0000000..0842d1d --- /dev/null +++ b/shiftflow/templates/shiftflow/maintenance.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} +{% load l10n %} + +{% block content %} +
+
+

Обслуживание сервера

+
+ +
+
+ Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере. +
+ +
+
+ DXF +
+
+ + {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+ +
+
+ Пакетное обновление пробегает по сделкам в статусах «Зашла» и «В работе». +
+
+ +
+
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/task_create.html b/shiftflow/templates/shiftflow/task_create.html index 6a381cd..5707d7c 100644 --- a/shiftflow/templates/shiftflow/task_create.html +++ b/shiftflow/templates/shiftflow/task_create.html @@ -35,7 +35,7 @@
-
+
{{ form.drawing_name }} {% for e in form.drawing_name.errors %}
{{ e }}
{% endfor %} @@ -50,7 +50,7 @@ {{ form.size_value }} {% for e in form.size_value.errors %}
{{ e }}
{% endfor %}
-
+
{{ form.is_bend }} diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 5807423..91094dc 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -8,6 +8,7 @@ from .views import ( DealUpsertView, IndexView, ItemUpdateView, + MaintenanceView, MaterialCategoryUpsertView, MaterialDetailView, MaterialUpsertView, @@ -32,6 +33,7 @@ urlpatterns = [ path('planning/task//items/', TaskItemsView.as_view(), name='task_items'), path('customers/', CustomersView.as_view(), name='customers'), path('customers//', CustomerDealsView.as_view(), name='customer_deals'), + path('maintenance/', MaintenanceView.as_view(), name='maintenance'), path('planning/add/', PlanningAddView.as_view(), name='planning_add'), path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'), path('planning/deal//json/', DealDetailView.as_view(), name='deal_json'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 4a6efed..1e45b60 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -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) diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index a38a1cb..9f1eba5 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -23,7 +23,7 @@ Заказчик {% endif %} - + {% if user_role in 'admin,technologist,master,operator' %} {% endif %} @@ -31,6 +31,12 @@ {% if user_role in 'admin,technologist,clerk' %} {% endif %} + + {% if user_role == 'admin' %} + + {% endif %}