it_manager/locations: редактор шаблона этикеток, раздельная печать QR/EAN и карточка поставщика.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,9 +11,21 @@ from barcode.writer import ImageWriter
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# Внутренний префикс EAN (2 = локальное использование) + 00 → 200…
|
||||
_EAN_PREFIX = '200'
|
||||
|
||||
TEXT_BLOCK_TYPES = frozenset({'model', 'type', 'inventory', 'serial', 'custom'})
|
||||
CODE_BLOCK_TYPES = frozenset({'qr', 'ean13'})
|
||||
|
||||
|
||||
def parse_label_codes(raw: str | None) -> str:
|
||||
"""Нормализация: qr | ean | both."""
|
||||
value = (raw or 'both').strip().lower()
|
||||
if value in ('qr', 'ean', 'both'):
|
||||
return value
|
||||
if value in ('ean13', 'barcode'):
|
||||
return 'ean'
|
||||
return 'both'
|
||||
|
||||
|
||||
def equipment_ean13_body(pk: int) -> str:
|
||||
"""12 цифр без контрольной: 200 + pk (9 знаков)."""
|
||||
@@ -25,7 +37,6 @@ def equipment_ean13_body(pk: int) -> str:
|
||||
def equipment_ean13(pk: int) -> str:
|
||||
"""Полный EAN-13 (13 цифр) для единицы оборудования."""
|
||||
EAN13 = get_barcode_class('ean13')
|
||||
# python-barcode сам считает check digit из 12 цифр
|
||||
code = EAN13(equipment_ean13_body(pk), writer=ImageWriter())
|
||||
return code.get_fullcode()
|
||||
|
||||
@@ -78,18 +89,131 @@ def ean13_png_base64(pk: int) -> tuple[str, str]:
|
||||
return _png_b64(img), code.get_fullcode()
|
||||
|
||||
|
||||
def label_payload_for_unit(unit) -> dict[str, Any]:
|
||||
def get_default_template():
|
||||
from apps.it_manager.models import EquipmentLabelTemplate
|
||||
return EquipmentLabelTemplate.get_default()
|
||||
|
||||
|
||||
def _block_visible(block_type: str, codes: str) -> bool:
|
||||
if block_type in TEXT_BLOCK_TYPES:
|
||||
return True
|
||||
if block_type == 'qr':
|
||||
return codes in ('qr', 'both')
|
||||
if block_type == 'ean13':
|
||||
return codes in ('ean', 'both')
|
||||
return False
|
||||
|
||||
|
||||
def _text_for_block(block_type: str, block: dict, fields: dict[str, str]) -> str:
|
||||
if block_type == 'custom':
|
||||
return (block.get('text') or '').strip()
|
||||
if block_type == 'model':
|
||||
return fields['model']
|
||||
if block_type == 'type':
|
||||
return fields['type_name']
|
||||
if block_type == 'inventory':
|
||||
return fields['inventory'] or f"EQ{fields['id']}"
|
||||
if block_type == 'serial':
|
||||
serial = fields['serial']
|
||||
return f'S/N {serial}' if serial else ''
|
||||
return ''
|
||||
|
||||
|
||||
def label_payload_for_unit(unit, *, codes: str = 'both', template=None) -> dict[str, Any]:
|
||||
codes = parse_label_codes(codes)
|
||||
if template is None:
|
||||
template = get_default_template()
|
||||
|
||||
qr_text = equipment_qr_payload(unit)
|
||||
ean_b64, ean_digits = ean13_png_base64(unit.pk)
|
||||
need_qr = codes in ('qr', 'both')
|
||||
need_ean = codes in ('ean', 'both')
|
||||
|
||||
qr_png = qr_png_base64(qr_text) if need_qr else ''
|
||||
ean_b64, ean_digits = ('', '')
|
||||
if need_ean:
|
||||
ean_b64, ean_digits = ean13_png_base64(unit.pk)
|
||||
|
||||
model = unit.equipment_model
|
||||
return {
|
||||
fields = {
|
||||
'id': unit.pk,
|
||||
'model': str(model) if model else '—',
|
||||
'type_name': model.equipment_type.name if model and model.equipment_type_id else '',
|
||||
'serial': unit.serial_number or '',
|
||||
'inventory': (unit.inventory_number or '').strip(),
|
||||
}
|
||||
|
||||
blocks_out = []
|
||||
for raw in (template.layout or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
btype = (raw.get('type') or '').strip()
|
||||
if not _block_visible(btype, codes):
|
||||
continue
|
||||
block = {
|
||||
'id': raw.get('id') or btype,
|
||||
'type': btype,
|
||||
'x_mm': float(raw.get('x_mm') or 0),
|
||||
'y_mm': float(raw.get('y_mm') or 0),
|
||||
'w_mm': float(raw.get('w_mm') or 10),
|
||||
'h_mm': float(raw.get('h_mm') or 10),
|
||||
'font_pt': float(raw.get('font_pt') or 9),
|
||||
'bold': bool(raw.get('bold')),
|
||||
'align': (raw.get('align') or 'left'),
|
||||
'text': '',
|
||||
'png': '',
|
||||
}
|
||||
if btype == 'qr':
|
||||
block['png'] = qr_png
|
||||
block['text'] = qr_text
|
||||
elif btype == 'ean13':
|
||||
block['png'] = ean_b64
|
||||
block['text'] = ean_digits
|
||||
else:
|
||||
block['text'] = _text_for_block(btype, raw, fields)
|
||||
blocks_out.append(block)
|
||||
|
||||
return {
|
||||
'id': unit.pk,
|
||||
'model': fields['model'],
|
||||
'type_name': fields['type_name'],
|
||||
'serial': fields['serial'],
|
||||
'inventory': fields['inventory'],
|
||||
'qr_payload': qr_text,
|
||||
'qr_png': qr_png_base64(qr_text),
|
||||
'qr_png': qr_png,
|
||||
'ean13': ean_digits,
|
||||
'ean_png': ean_b64,
|
||||
'codes': codes,
|
||||
'width_mm': float(template.width_mm),
|
||||
'height_mm': float(template.height_mm),
|
||||
'blocks': blocks_out,
|
||||
}
|
||||
|
||||
|
||||
def normalize_layout(raw_blocks) -> list[dict]:
|
||||
"""Валидация layout из редактора."""
|
||||
allowed = CODE_BLOCK_TYPES | TEXT_BLOCK_TYPES
|
||||
out = []
|
||||
if not isinstance(raw_blocks, list):
|
||||
return out
|
||||
for i, raw in enumerate(raw_blocks):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
btype = (raw.get('type') or '').strip()
|
||||
if btype not in allowed:
|
||||
continue
|
||||
bid = (raw.get('id') or f'{btype}-{i}').strip()[:40]
|
||||
out.append({
|
||||
'id': bid,
|
||||
'type': btype,
|
||||
'x_mm': max(0.0, float(raw.get('x_mm') or 0)),
|
||||
'y_mm': max(0.0, float(raw.get('y_mm') or 0)),
|
||||
'w_mm': max(2.0, float(raw.get('w_mm') or 10)),
|
||||
'h_mm': max(2.0, float(raw.get('h_mm') or 10)),
|
||||
'font_pt': max(6.0, min(24.0, float(raw.get('font_pt') or 9))),
|
||||
'bold': bool(raw.get('bold')),
|
||||
'align': (raw.get('align') or 'left') if (raw.get('align') or 'left') in (
|
||||
'left', 'center', 'right',
|
||||
) else 'left',
|
||||
'text': (raw.get('text') or '')[:200] if btype == 'custom' else '',
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 13:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_default_template(apps, schema_editor):
|
||||
from apps.it_manager.models.label_template import DEFAULT_LABEL_LAYOUT
|
||||
|
||||
Template = apps.get_model('it_manager', 'EquipmentLabelTemplate')
|
||||
if Template.objects.filter(code='default').exists():
|
||||
return
|
||||
Template.objects.create(
|
||||
code='default',
|
||||
name='Этикетка оборудования',
|
||||
width_mm=70.0,
|
||||
height_mm=42.0,
|
||||
layout=list(DEFAULT_LABEL_LAYOUT),
|
||||
)
|
||||
|
||||
|
||||
def unseed_default_template(apps, schema_editor):
|
||||
Template = apps.get_model('it_manager', 'EquipmentLabelTemplate')
|
||||
Template.objects.filter(code='default').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0022_worklog_catalog_work'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EquipmentLabelTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.SlugField(default='default', max_length=40, unique=True, verbose_name='Код')),
|
||||
('name', models.CharField(default='Этикетка оборудования', max_length=100, verbose_name='Название')),
|
||||
('width_mm', models.FloatField(default=70.0, verbose_name='Ширина (мм)')),
|
||||
('height_mm', models.FloatField(default=42.0, verbose_name='Высота (мм)')),
|
||||
('layout', models.JSONField(blank=True, default=list, verbose_name='Блоки')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто обновил')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Шаблон этикетки',
|
||||
'verbose_name_plural': 'Шаблоны этикеток',
|
||||
},
|
||||
),
|
||||
migrations.RunPython(seed_default_template, unseed_default_template),
|
||||
]
|
||||
@@ -3,3 +3,4 @@ from .tickets import Ticket, TicketComment
|
||||
from .providers import InternetProvider, InternetContract, InternetContractPayment
|
||||
from .worklogs import WorkLog, ServiceCall
|
||||
from .consumables import Consumable
|
||||
from .label_template import EquipmentLabelTemplate, DEFAULT_LABEL_LAYOUT
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
DEFAULT_LABEL_LAYOUT = [
|
||||
{
|
||||
'id': 'qr',
|
||||
'type': 'qr',
|
||||
'x_mm': 2.0,
|
||||
'y_mm': 2.0,
|
||||
'w_mm': 22.0,
|
||||
'h_mm': 22.0,
|
||||
'font_pt': 9,
|
||||
'bold': False,
|
||||
'align': 'left',
|
||||
'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'model',
|
||||
'type': 'model',
|
||||
'x_mm': 26.0,
|
||||
'y_mm': 2.0,
|
||||
'w_mm': 40.0,
|
||||
'h_mm': 8.0,
|
||||
'font_pt': 10,
|
||||
'bold': True,
|
||||
'align': 'left',
|
||||
'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'type',
|
||||
'type': 'type',
|
||||
'x_mm': 26.0,
|
||||
'y_mm': 10.0,
|
||||
'w_mm': 40.0,
|
||||
'h_mm': 5.0,
|
||||
'font_pt': 8,
|
||||
'bold': False,
|
||||
'align': 'left',
|
||||
'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'inventory',
|
||||
'type': 'inventory',
|
||||
'x_mm': 26.0,
|
||||
'y_mm': 15.5,
|
||||
'w_mm': 40.0,
|
||||
'h_mm': 5.0,
|
||||
'font_pt': 10,
|
||||
'bold': False,
|
||||
'align': 'left',
|
||||
'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'serial',
|
||||
'type': 'serial',
|
||||
'x_mm': 26.0,
|
||||
'y_mm': 21.0,
|
||||
'w_mm': 40.0,
|
||||
'h_mm': 4.0,
|
||||
'font_pt': 8,
|
||||
'bold': False,
|
||||
'align': 'left',
|
||||
'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'ean',
|
||||
'type': 'ean13',
|
||||
'x_mm': 2.0,
|
||||
'y_mm': 26.0,
|
||||
'w_mm': 64.0,
|
||||
'h_mm': 14.0,
|
||||
'font_pt': 8,
|
||||
'bold': False,
|
||||
'align': 'center',
|
||||
'text': '',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class EquipmentLabelTemplate(models.Model):
|
||||
"""Общий шаблон инвентарной этикетки (singleton code=default)."""
|
||||
|
||||
code = models.SlugField('Код', max_length=40, unique=True, default='default')
|
||||
name = models.CharField('Название', max_length=100, default='Этикетка оборудования')
|
||||
width_mm = models.FloatField('Ширина (мм)', default=70.0)
|
||||
height_mm = models.FloatField('Высота (мм)', default=42.0)
|
||||
layout = models.JSONField('Блоки', default=list, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True, related_name='+',
|
||||
verbose_name='Кто обновил',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Шаблон этикетки'
|
||||
verbose_name_plural = 'Шаблоны этикеток'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_default(cls):
|
||||
obj, created = cls.objects.get_or_create(
|
||||
code='default',
|
||||
defaults={
|
||||
'name': 'Этикетка оборудования',
|
||||
'width_mm': 70.0,
|
||||
'height_mm': 42.0,
|
||||
'layout': list(DEFAULT_LABEL_LAYOUT),
|
||||
},
|
||||
)
|
||||
if not created and not obj.layout:
|
||||
obj.layout = list(DEFAULT_LABEL_LAYOUT)
|
||||
obj.save(update_fields=['layout'])
|
||||
return obj
|
||||
@@ -436,17 +436,74 @@ class ItManagerEquipmentTests(TestCase):
|
||||
self.assertEqual(len(ean), 13)
|
||||
self.assertTrue(ean.startswith('200'))
|
||||
|
||||
r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}')
|
||||
r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=both')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'INV-LBL-1')
|
||||
self.assertContains(r, 'data:image/png;base64,')
|
||||
self.assertContains(r, ean)
|
||||
|
||||
r_qr = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=qr')
|
||||
self.assertEqual(r_qr.status_code, 200)
|
||||
self.assertContains(r_qr, 'INV-LBL-1')
|
||||
self.assertNotContains(r_qr, f'alt="{ean}"')
|
||||
|
||||
r_ean = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=ean')
|
||||
self.assertEqual(r_ean.status_code, 200)
|
||||
self.assertContains(r_ean, ean)
|
||||
self.assertNotContains(r_ean, f'alt="INV-LBL-1"')
|
||||
|
||||
r2 = self.client.get('/it-manager/', {'tab': 'equipment', 'q': ean})
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
serials = [e.serial_number for e in r2.context['equipment_page']]
|
||||
self.assertIn('LBL-SN-1', serials)
|
||||
|
||||
def test_label_template_editor(self):
|
||||
import json
|
||||
|
||||
from apps.it_manager.models import EquipmentLabelTemplate
|
||||
|
||||
r = self.client.get('/it-manager/equipment/labels/template/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'Шаблон этикетки')
|
||||
|
||||
tmpl = EquipmentLabelTemplate.get_default()
|
||||
payload = {
|
||||
'width_mm': 80,
|
||||
'height_mm': 50,
|
||||
'layout': [
|
||||
{
|
||||
'id': 'qr1', 'type': 'qr',
|
||||
'x_mm': 3, 'y_mm': 3, 'w_mm': 20, 'h_mm': 20,
|
||||
'font_pt': 9, 'bold': False, 'align': 'left', 'text': '',
|
||||
},
|
||||
{
|
||||
'id': 'm1', 'type': 'model',
|
||||
'x_mm': 26, 'y_mm': 3, 'w_mm': 40, 'h_mm': 8,
|
||||
'font_pt': 10, 'bold': True, 'align': 'left', 'text': '',
|
||||
},
|
||||
],
|
||||
}
|
||||
r_save = self.client.post(
|
||||
'/it-manager/equipment/labels/template/',
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r_save.status_code, 200)
|
||||
data = r_save.json()
|
||||
self.assertTrue(data.get('ok'))
|
||||
tmpl.refresh_from_db()
|
||||
self.assertEqual(tmpl.width_mm, 80)
|
||||
self.assertEqual(len(tmpl.layout), 2)
|
||||
|
||||
outsider = make_employee(username='no-it-labels')
|
||||
client2 = Client()
|
||||
login_2fa(client2, outsider)
|
||||
forbidden = client2.get('/it-manager/equipment/labels/template/')
|
||||
self.assertIn(forbidden.status_code, (403, 302, 200))
|
||||
if forbidden.status_code == 200:
|
||||
# deny_section_access может вернуть dashboard-фрагмент
|
||||
self.assertNotContains(forbidden, 'label-canvas')
|
||||
|
||||
def test_assignment_history_visible_in_card(self):
|
||||
equipment = make_equipment(serial_number='HIST-UI-1')
|
||||
emp = make_employee(first_name='Иван', last_name='Историков')
|
||||
|
||||
@@ -31,6 +31,11 @@ urlpatterns = [
|
||||
path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'),
|
||||
path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'),
|
||||
path('it-manager/equipment/labels/', views.equipment_labels_view, name='it_equipment_labels'),
|
||||
path(
|
||||
'it-manager/equipment/labels/template/',
|
||||
views.equipment_label_template_view,
|
||||
name='it_equipment_label_template',
|
||||
),
|
||||
path('it-manager/equipment/<int:pk>/update/', views.equipment_update_view, name='it_equipment_update'),
|
||||
path('it-manager/tickets/<int:pk>/update/', views.ticket_update_view, name='it_ticket_update'),
|
||||
path('it-manager/tickets/<int:pk>/comment/', views.ticket_comment_view, name='it_ticket_comment'),
|
||||
|
||||
@@ -752,27 +752,30 @@ def equipment_bulk_create_view(request):
|
||||
|
||||
@login_required
|
||||
def equipment_labels_view(request):
|
||||
"""Печатная страница этикеток QR + EAN-13 для выбранного оборудования."""
|
||||
"""Печатная страница этикеток по шаблону; codes=qr|ean|both."""
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.it_manager.equipment_labels import label_payload_for_unit
|
||||
from apps.it_manager.equipment_labels import (
|
||||
get_default_template, label_payload_for_unit, parse_label_codes,
|
||||
)
|
||||
|
||||
if not _is_it_staff(request.user):
|
||||
return deny_section_access(request)
|
||||
|
||||
codes = parse_label_codes(request.GET.get('codes'))
|
||||
raw_ids = request.GET.getlist('ids') or request.GET.get('ids', '').split(',')
|
||||
ids = []
|
||||
for part in raw_ids:
|
||||
part = (part or '').strip()
|
||||
if part.isdigit():
|
||||
ids.append(int(part))
|
||||
# порядок как в запросе, без дублей
|
||||
seen, ordered = set(), []
|
||||
for pk in ids:
|
||||
if pk not in seen:
|
||||
seen.add(pk)
|
||||
ordered.append(pk)
|
||||
|
||||
template = get_default_template()
|
||||
units_by_id = {
|
||||
u.pk: u
|
||||
for u in scoped_queryset(request.user, Equipment.objects.all())
|
||||
@@ -785,9 +788,61 @@ def equipment_labels_view(request):
|
||||
for pk in ordered:
|
||||
unit = units_by_id.get(pk)
|
||||
if unit is not None:
|
||||
labels.append(label_payload_for_unit(unit))
|
||||
labels.append(label_payload_for_unit(unit, codes=codes, template=template))
|
||||
|
||||
return render(request, 'pages/it-equipment-labels.html', {'labels': labels})
|
||||
return render(request, 'pages/it-equipment-labels.html', {
|
||||
'labels': labels,
|
||||
'codes': codes,
|
||||
'template_width_mm': template.width_mm,
|
||||
'template_height_mm': template.height_mm,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def equipment_label_template_view(request):
|
||||
"""Редактор общего шаблона этикетки (GET страница / POST JSON)."""
|
||||
import json
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.it_manager.equipment_labels import get_default_template, normalize_layout
|
||||
|
||||
if not _is_it_staff(request.user):
|
||||
return deny_section_access(request)
|
||||
|
||||
template = get_default_template()
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
payload = json.loads(request.body.decode('utf-8') or '{}')
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return JsonResponse({'ok': False, 'error': 'Некорректный JSON'}, status=400)
|
||||
try:
|
||||
width = float(payload.get('width_mm') or template.width_mm)
|
||||
height = float(payload.get('height_mm') or template.height_mm)
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse({'ok': False, 'error': 'Некорректный размер'}, status=400)
|
||||
if width < 20 or width > 200 or height < 15 or height > 200:
|
||||
return JsonResponse({'ok': False, 'error': 'Размер вне диапазона 20–200 × 15–200 мм'}, status=400)
|
||||
layout = normalize_layout(payload.get('layout'))
|
||||
if not layout:
|
||||
return JsonResponse({'ok': False, 'error': 'Добавьте хотя бы один блок'}, status=400)
|
||||
template.width_mm = width
|
||||
template.height_mm = height
|
||||
template.layout = layout
|
||||
template.updated_by = request.user
|
||||
template.save()
|
||||
return JsonResponse({'ok': True, 'updated_at': template.updated_at.isoformat()})
|
||||
|
||||
return render(request, 'pages/it-equipment-label-template.html', {
|
||||
'template': template,
|
||||
'template_json': json.dumps({
|
||||
'width_mm': template.width_mm,
|
||||
'height_mm': template.height_mm,
|
||||
'layout': template.layout or [],
|
||||
}, ensure_ascii=False),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
Reference in New Issue
Block a user