it_manager: справочник типовых работ в журнале WorkLog.

Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 13:38:07 +03:00
parent be7e5853cb
commit 31bd4c8fa7
11 changed files with 822 additions and 112 deletions
+24 -27
View File
@@ -611,23 +611,27 @@ class ServiceCallForm(forms.ModelForm):
@admin.register(WorkLog)
class WorkLogAdmin(ModelAdmin):
form = WorkLogForm
list_display = ['date', 'technician', 'work_type_display', 'location',
list_display = ['date', 'technician', 'catalog_display', 'location',
'hours_spent', 'ticket', 'created_at']
list_filter = ['work_type', 'technician', 'date']
search_fields = ['description', 'result', 'technician__last_name',
'technician__first_name']
list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type']
list_filter = ['catalog_work__parent', 'work_type', 'technician', 'date']
search_fields = ['description', 'result', 'catalog_work__name',
'technician__last_name', 'technician__first_name']
list_select_related = [
'technician', 'location', 'ticket', 'equipment',
'work_type', 'catalog_work', 'catalog_work__parent',
]
ordering = ['-date', '-created_at']
date_hierarchy = 'date'
raw_id_fields = ['catalog_work']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
(_('Работа'), {
'fields': ('date', 'technician', 'work_type', 'location'),
'fields': ('date', 'technician', 'catalog_work', 'details', 'location'),
}),
(_('Описание'), {
'fields': ('description', 'result', 'hours_spent'),
(_('Устарело / комментарий'), {
'fields': ('work_type', 'description', 'result', 'hours_spent'),
}),
(_('Связи'), {
'fields': ('equipment', 'ticket'),
@@ -638,25 +642,18 @@ class WorkLogAdmin(ModelAdmin):
}),
)
WORK_TYPE_COLORS = {
'maintenance': '#2563eb',
'installation': '#16a34a',
'repair': '#ca8a04',
'network': '#9333ea',
'software': '#0891b2',
'backup': '#6b7280',
'other': '#6b7280',
}
def work_type_display(self, obj):
color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280')
return format_html(
'<span style="color:{};font-weight:bold;">{}</span>',
color, obj.work_type.name
)
work_type_display.short_description = _('Вид работ')
work_type_display.admin_order_field = 'work_type__name'
def catalog_display(self, obj):
if obj.catalog_work_id:
cat = obj.catalog_work.parent.name if obj.catalog_work.parent_id else ''
label = obj.catalog_work.name
if cat:
return f'{cat}: {label}'
return label
if obj.work_type_id:
return obj.work_type.name
return (obj.description or '—')[:60]
catalog_display.short_description = _('Типовая работа')
catalog_display.admin_order_field = 'catalog_work__name'
def save_model(self, request, obj, form, change):
if not change and not obj.technician_id:
obj.technician = request.user
@@ -0,0 +1,31 @@
"""Загрузить consolidated-справочник типовых IT-работ в Directory."""
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from apps.it_manager.work_directory import DEFAULT_JSON, seed_from_json
class Command(BaseCommand):
help = (
'Идемпотентный seed категорий (it_work_category) и типовых работ (it_work) '
'из work-directory-consolidated JSON.'
)
def add_arguments(self, parser):
parser.add_argument(
'json_path',
nargs='?',
default=str(DEFAULT_JSON),
help=f'Путь к work-directory.json (по умолчанию: {DEFAULT_JSON})',
)
def handle(self, *args, **options):
path = Path(options['json_path'])
if not path.is_file():
raise CommandError(f'Файл не найден: {path}')
stats = seed_from_json(path)
self.stdout.write(self.style.SUCCESS(
f'Справочник загружен: категорий={stats["categories"]}, '
f'работ={stats["works"]} ({path})'
))
@@ -0,0 +1,51 @@
# WorkLog: catalog_work + details; work_type/description optional
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_seed_pvms_visit_permissions'),
('it_manager', '0021_seed_gpon_connection_type'),
]
operations = [
migrations.AlterField(
model_name='worklog',
name='work_type',
field=models.ForeignKey(
blank=True,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='worklogs_by_type',
to='core.directory',
verbose_name='Вид работ',
),
),
migrations.AlterField(
model_name='worklog',
name='description',
field=models.TextField(blank=True, verbose_name='Комментарий'),
),
migrations.AddField(
model_name='worklog',
name='catalog_work',
field=models.ForeignKey(
blank=True,
limit_choices_to={'directory_type__code': 'it_work', 'is_active': True},
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='worklogs_by_catalog',
to='core.directory',
verbose_name='Типовая работа',
),
),
migrations.AddField(
model_name='worklog',
name='details',
field=models.JSONField(blank=True, default=dict, verbose_name='Уточнения'),
),
]
+22 -4
View File
@@ -2,8 +2,6 @@ from django.db import models
from django.conf import settings
from django.utils import timezone
from core.models.directories import directory_default
class WorkLog(models.Model):
date = models.DateField('Дата', default=timezone.localdate, db_index=True)
@@ -16,12 +14,21 @@ class WorkLog(models.Model):
null=True, blank=True,
related_name='work_logs', verbose_name='Локация',
)
# Устаревший грубый вид (~7 типов); для новых записей не заполняется.
work_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
related_name='worklogs_by_type', verbose_name='Вид работ',
null=True, blank=True,
)
description = models.TextField('Описание работ')
catalog_work = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'it_work', 'is_active': True},
related_name='worklogs_by_catalog', verbose_name='Типовая работа',
null=True, blank=True,
)
details = models.JSONField('Уточнения', default=dict, blank=True)
description = models.TextField('Комментарий', blank=True)
result = models.TextField('Результат', blank=True)
hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1,
null=True, blank=True)
@@ -49,7 +56,18 @@ class WorkLog(models.Model):
]
def __str__(self):
return f'{self.work_type.name} — {self.technician} — {self.date:%d.%m.%Y}'
if self.catalog_work_id:
title = self.catalog_work.name
elif self.work_type_id:
title = self.work_type.name
else:
title = (self.description or '—')[:40]
return f'{title} — {self.technician} — {self.date:%d.%m.%Y}'
@property
def display_title(self) -> str:
from apps.it_manager.work_directory import worklog_display_title
return worklog_display_title(self)
class ServiceCall(models.Model):
+139 -7
View File
@@ -1,6 +1,9 @@
"""Тести IT-порталу: доступ через групу IT, інвентаризація обладнання
(зі списанням історії видачі), черга заявок, журнал робіт."""
from datetime import date
import json
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
@@ -9,6 +12,7 @@ from django.test import Client, TestCase
from apps.it_manager.models import (
Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog,
)
from apps.it_manager.work_directory import seed_from_json
from core.models.directories import Directory
from core.test_utils import login_2fa, grant_all_locations
@@ -58,6 +62,50 @@ def make_equipment(**overrides):
return Equipment.objects.create(**defaults)
MINI_WORK_CATALOG = {
'schema_version': 1,
'status': 'test',
'detail_fields': {
'device_type': {'label': 'Вид устройства'},
'system': {'label': 'Система'},
},
'categories': [
{
'id': 'helpdesk',
'name': 'Поддержка',
'works': [
{'id': 'helpdesk.test1', 'name': 'Приём обращений'},
],
},
{
'id': 'printing',
'name': 'Печать',
'works': [
{
'id': 'printing.test1',
'name': 'Восстановление печати',
'required_details': ['device_type'],
},
],
},
],
}
def seed_mini_work_catalog():
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as fh:
json.dump(MINI_WORK_CATALOG, fh, ensure_ascii=False)
path = fh.name
try:
return seed_from_json(path)
finally:
Path(path).unlink(missing_ok=True)
def catalog_work_id(code):
return Directory.objects.get(directory_type__code='it_work', code=code).id
class ItManagerAccessTests(TestCase):
"""Сторінка й усі мутації доступні лише групі IT (або суперюзеру)."""
@@ -432,9 +480,30 @@ class ItManagerTicketTests(TestCase):
self.assertTrue(ticket.comments.first().is_internal)
class ItWorkDirectorySeedTests(TestCase):
def test_seed_upserts_categories_and_works(self):
stats = seed_mini_work_catalog()
self.assertEqual(stats['categories'], 2)
self.assertEqual(stats['works'], 2)
self.assertEqual(
Directory.objects.filter(directory_type__code='it_work_category', is_active=True).count(),
2,
)
work = Directory.objects.get(directory_type__code='it_work', code='printing.test1')
self.assertEqual(work.extra_data.get('required_details'), ['device_type'])
# idempotent
stats2 = seed_mini_work_catalog()
self.assertEqual(stats2['works'], 2)
self.assertEqual(
Directory.objects.filter(directory_type__code='it_work').exclude(code='_meta').count(),
2,
)
class ItManagerWorkLogTests(TestCase):
def setUp(self):
seed_mini_work_catalog()
self.it_user = make_employee(is_staff=False)
self.it_user.groups.add(Group.objects.get(name='IT'))
grant_all_locations(self.it_user)
@@ -443,27 +512,65 @@ class ItManagerWorkLogTests(TestCase):
def test_create_worklog(self):
r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'maintenance', 'description': 'Профілактика серверної',
'catalog_work_id': str(catalog_work_id('helpdesk.test1')),
'description': 'Комментарий к обращению',
'hours_spent': '2.5',
})
self.assertEqual(r.status_code, 200)
log = WorkLog.objects.get(description='Профілактика серверної')
log = WorkLog.objects.get(description='Комментарий к обращению')
self.assertEqual(log.technician_id, self.it_user.id)
self.assertEqual(log.catalog_work.code, 'helpdesk.test1')
self.assertIsNone(log.work_type_id)
self.assertEqual(float(log.hours_spent), 2.5)
self.assertContains(r, 'Запись о выполненной работе сохранена')
def test_create_worklog_requires_description(self):
def test_create_worklog_requires_catalog_and_details(self):
before = WorkLog.objects.count()
r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'maintenance', 'description': ' ',
'description': 'без работы',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before)
self.assertContains(r, 'Укажите описание работ')
self.assertContains(r, 'типовую работу')
r = self.client.post('/it-manager/worklog/create/', {
'catalog_work_id': str(catalog_work_id('printing.test1')),
'description': '',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before)
self.assertContains(r, 'обязательные уточнения')
r = self.client.post('/it-manager/worklog/create/', {
'catalog_work_id': str(catalog_work_id('printing.test1')),
'detail_device_type': 'МФУ',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before + 1)
log = WorkLog.objects.latest('pk')
self.assertEqual(log.details.get('device_type'), 'МФУ')
def test_worklog_list_shows_catalog_title(self):
WorkLog.objects.create(
technician=self.it_user,
catalog_work_id=catalog_work_id('printing.test1'),
details={'device_type': 'Принтер'},
description='на складе',
)
WorkLog.objects.create(
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'maintenance'),
description='Старая запись без справочника',
)
r = self.client.get('/it-manager/?tab=worklog')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Восстановление печати')
self.assertContains(r, 'без справочника')
self.assertContains(r, 'Старая запись без справочника')
def test_create_worklog_linked_to_ticket(self):
from apps.it_manager.models import Ticket
from core.models.directories import Directory, DirectoryType
from core.models.directories import DirectoryType
ttype, _ = DirectoryType.objects.get_or_create(code='ticket_type', defaults={'name': 'Тип заявки'})
ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'})
@@ -486,7 +593,7 @@ class ItManagerWorkLogTests(TestCase):
description='Каса не відкривається',
)
r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'repair',
'catalog_work_id': str(catalog_work_id('helpdesk.test1')),
'description': 'Перезапуск касового ПЗ',
'ticket_id': ticket.pk,
'hours_spent': '1',
@@ -497,6 +604,31 @@ class ItManagerWorkLogTests(TestCase):
self.assertContains(r, 'Работа привязана к заявке')
self.assertContains(r, ticket.number)
def test_monthly_builder_mix_catalog_and_legacy(self):
from apps.reports.it_monthly.builder import build_monthly_report
WorkLog.objects.create(
date=date(2026, 5, 10),
technician=self.it_user,
catalog_work_id=catalog_work_id('printing.test1'),
details={'device_type': 'МФУ'},
description='на А3',
)
WorkLog.objects.create(
date=date(2026, 5, 11),
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'repair'),
description='Старый свободный текст',
)
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
works = [
t['work']
for emp in report['employees']
for t in emp.get('tasks') or []
]
self.assertTrue(any('Восстановление печати' in w and 'МФУ' in w for w in works))
self.assertTrue(any('Старый свободный текст' in w for w in works))
class ItManagerWorkLogFilterEditTests(TestCase):
"""Фильтр по сотруднику и inline-редактирование для руководителя."""
+106 -49
View File
@@ -15,6 +15,13 @@ from apps.it_manager.models import (
InternetContract, InternetProvider,
ServiceCall, Ticket, TicketComment, WorkLog,
)
from apps.it_manager.work_directory import (
catalog_payload_for_ui,
detail_labels_from_db,
get_catalog_work,
parse_details_from_post,
validate_worklog_details,
)
from apps.it_manager.monthly_report import (
MONTHS_RU,
user_can_manage_it_monthly_report,
@@ -306,7 +313,9 @@ def _it_manager_context(request):
'comments__author',
models.Prefetch(
'work_logs',
queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related('technician', 'work_type').order_by('-date', '-created_at'),
queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related(
'technician', 'work_type', 'catalog_work', 'catalog_work__parent',
).order_by('-date', '-created_at'),
),
)
.annotate(worklogs_count=models.Count('work_logs', distinct=True))
@@ -330,6 +339,8 @@ def _it_manager_context(request):
month_start = timezone.localdate().replace(day=1)
worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip()
worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None
worklog_category_raw = (request.GET.get('worklog_category') or '').strip()
worklog_category_id = int(worklog_category_raw) if worklog_category_raw.isdigit() else None
worklog_month_raw = (request.GET.get('worklog_month') or '').strip()
worklog_month_start = None
@@ -346,6 +357,7 @@ def _it_manager_context(request):
worklogs_qs = (
scoped_queryset(request.user, WorkLog.objects.all()).select_related(
'technician', 'location', 'equipment', 'ticket', 'work_type',
'catalog_work', 'catalog_work__parent',
)
.prefetch_related(
models.Prefetch(
@@ -357,11 +369,15 @@ def _it_manager_context(request):
)
if worklog_technician_id:
worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id)
if worklog_category_id:
worklogs_qs = worklogs_qs.filter(catalog_work__parent_id=worklog_category_id)
if worklog_month_start and worklog_month_end:
worklogs_qs = worklogs_qs.filter(
date__gte=worklog_month_start, date__lte=worklog_month_end,
)
worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page')
it_work_catalog = catalog_payload_for_ui()
worklog_detail_labels = it_work_catalog.get('detail_labels') or detail_labels_from_db()
# Статистика: выбранный месяц или текущий календарный
if worklog_month_start and worklog_month_end:
@@ -468,10 +484,13 @@ def _it_manager_context(request):
'worklog_types': _directory_choices('worklog_type'),
'worklog_technicians': worklog_technicians,
'worklog_technician_id': worklog_technician_id,
'worklog_category_id': worklog_category_id,
'worklog_month_id': worklog_month_id,
'worklog_month_choices': worklog_month_choices,
'worklog_stats_label': worklog_stats_label,
'it_can_edit_worklogs': can_edit_worklogs,
'it_work_catalog': it_work_catalog,
'worklog_detail_labels': worklog_detail_labels,
'month_hours': month_hours,
'month_count': month_count,
@@ -519,6 +538,7 @@ def it_manager_view(request):
elif (
'worklog_technician' in request.GET
or 'worklog_month' in request.GET
or 'worklog_category' in request.GET
or request.GET.get('worklogs_page')
):
context['it_active_tab'] = 'worklog'
@@ -887,49 +907,53 @@ def worklog_create_view(request):
worklog_error = ''
worklog_message = ''
if request.method == 'POST':
catalog_work = get_catalog_work(request.POST.get('catalog_work_id'))
description = request.POST.get('description', '').strip()
work_type_raw = request.POST.get('work_type', '')
work_type_id = _resolve_directory('worklog_type', work_type_raw)
details = parse_details_from_post(request.POST, catalog_work)
if not description:
worklog_error = 'Укажите описание работ.'
elif not work_type_id or work_type_raw == '__new__':
worklog_error = 'Выберите вид работ.'
if not catalog_work:
worklog_error = 'Выберите типовую работу из справочника.'
else:
try:
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
except ValueError:
log_date = timezone.localdate()
try:
hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None
except ValueError:
hours_spent = None
location_id = request.POST.get('location_id') or None
if not location_id and not has_all_locations(request.user):
return HttpResponseForbidden('Укажите доступную локацию.')
equipment_id = request.POST.get('equipment_id') or None
ticket_id = request.POST.get('ticket_id') or None
try:
WorkLog.objects.create(
date=log_date,
technician=request.user,
work_type_id=work_type_id,
location_id=location_id,
equipment_id=equipment_id,
ticket_id=ticket_id,
description=description,
result=request.POST.get('result', '').strip(),
hours_spent=hours_spent,
)
except Exception:
worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
details_err = validate_worklog_details(catalog_work, details)
if details_err:
worklog_error = details_err
else:
if ticket_id:
worklog_message = 'Работа привязана к заявке и сохранена.'
try:
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
except ValueError:
log_date = timezone.localdate()
try:
hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None
except ValueError:
hours_spent = None
location_id = request.POST.get('location_id') or None
if not location_id and not has_all_locations(request.user):
return HttpResponseForbidden('Укажите доступную локацию.')
equipment_id = request.POST.get('equipment_id') or None
ticket_id = request.POST.get('ticket_id') or None
try:
WorkLog.objects.create(
date=log_date,
technician=request.user,
catalog_work=catalog_work,
details=details,
work_type_id=None,
location_id=location_id,
equipment_id=equipment_id,
ticket_id=ticket_id,
description=description,
result=request.POST.get('result', '').strip(),
hours_spent=hours_spent,
)
except Exception:
worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
else:
worklog_message = 'Запись о выполненной работе сохранена.'
if ticket_id:
worklog_message = 'Работа привязана к заявке и сохранена.'
else:
worklog_message = 'Запись о выполненной работе сохранена.'
context = _it_manager_context(request)
context['it_active_tab'] = 'worklog'
@@ -953,19 +977,47 @@ def worklog_update_view(request, pk):
log = get_object_or_404(scoped_queryset(request.user, WorkLog.objects.all()), pk=pk)
description = request.POST.get('description', '').strip()
work_type_raw = request.POST.get('work_type', '')
work_type_id = _resolve_directory('worklog_type', work_type_raw)
catalog_raw = (request.POST.get('catalog_work_id') or '').strip()
catalog_work = get_catalog_work(catalog_raw) if catalog_raw else None
# Если запись уже из справочника — сохраняем FK даже без смены в форме
if not catalog_work and log.catalog_work_id and not catalog_raw:
catalog_work = log.catalog_work
if not description:
work_type_raw = request.POST.get('work_type', '')
work_type_id = _resolve_directory('worklog_type', work_type_raw) if work_type_raw else None
if catalog_work:
details = parse_details_from_post(request.POST, catalog_work)
# если detail_* не пришли — оставить прежние значения для ключей без POST
if log.catalog_work_id == catalog_work.pk and log.details:
merged = dict(log.details)
for key in details:
if f'detail_{key}' in request.POST:
merged[key] = details[key]
details = merged
details_err = validate_worklog_details(catalog_work, details)
if details_err:
return HttpResponse(
f'<span style="color:var(--color-destructive); font-size:0.75rem;">{details_err}</span>',
status=400,
)
elif log.catalog_work_id:
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
status=400,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите типовую работу</span>',
status=400,
)
else:
# Историческая запись без справочника
if not description:
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
status=400,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
status=400,
)
try:
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
@@ -991,7 +1043,12 @@ def worklog_update_view(request, pk):
return HttpResponseForbidden('Укажите доступную локацию.')
log.date = log_date
log.technician_id = technician_id
log.work_type_id = work_type_id
if catalog_work:
log.catalog_work = catalog_work
log.details = details
log.work_type_id = None
else:
log.work_type_id = work_type_id
log.description = description
log.result = request.POST.get('result', '').strip()
log.hours_spent = hours_spent
+258
View File
@@ -0,0 +1,258 @@
"""Справочник типовых IT-работ (Directory it_work_category / it_work)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from django.db import transaction
from core.models.directories import Directory, DirectoryType
CAT_TYPE = 'it_work_category'
WORK_TYPE = 'it_work'
def _default_json_path() -> Path:
acore_root = Path(__file__).resolve().parents[2]
sibling = (
acore_root.parent
/ 'report-tool'
/ 'docs'
/ 'work-directory-consolidated'
/ 'work-directory.json'
)
if sibling.is_file():
return sibling
return Path(
'/home/lokses/Рабочий стол/apms/report-tool/docs/'
'work-directory-consolidated/work-directory.json'
)
DEFAULT_JSON = _default_json_path()
def load_catalog_json(path: str | Path | None = None) -> dict:
p = Path(path) if path else DEFAULT_JSON
return json.loads(p.read_text(encoding='utf-8'))
def detail_field_labels(catalog: dict | None = None) -> dict[str, str]:
"""code → label для UI."""
data = catalog if catalog is not None else {}
fields = data.get('detail_fields') or {}
out: dict[str, str] = {}
for code, meta in fields.items():
if isinstance(meta, dict):
out[code] = meta.get('label') or code
else:
out[code] = str(meta)
return out
@transaction.atomic
def seed_from_json(path: str | Path | None = None) -> dict[str, int]:
"""Идемпотентный upsert категорий и работ. Возвращает счётчики."""
data = load_catalog_json(path)
cat_type, _ = DirectoryType.objects.get_or_create(
code=CAT_TYPE,
defaults={
'name': 'Категория IT-работ',
'hierarchy_type': 'flexible',
'description': 'Категории справочника типовых IT-работ',
},
)
if cat_type.hierarchy_type != 'flexible':
cat_type.hierarchy_type = 'flexible'
cat_type.save(update_fields=['hierarchy_type', 'updated_at'])
work_type, _ = DirectoryType.objects.get_or_create(
code=WORK_TYPE,
defaults={
'name': 'Типовая IT-работа',
'hierarchy_type': 'flexible',
'description': 'Типовые работы IT-отдела',
},
)
if work_type.hierarchy_type != 'flexible':
work_type.hierarchy_type = 'flexible'
work_type.save(update_fields=['hierarchy_type', 'updated_at'])
labels = detail_field_labels(data)
# храним лейблы на типе работ в description JSON-хвосте некрасиво —
# кладём в первую категорию-маркер? Лучше отдельный Directory code=_meta
meta, _ = Directory.objects.update_or_create(
directory_type=work_type,
code='_meta',
defaults={
'name': 'Метаданные справочника IT-работ',
'is_active': False,
'extra_data': {
'detail_fields': data.get('detail_fields') or {},
'labels': labels,
'source_status': data.get('status', ''),
},
},
)
cats_upserted = 0
works_upserted = 0
cat_by_code: dict[str, Directory] = {}
for cat in data.get('categories') or []:
code = cat['id']
obj, _ = Directory.objects.update_or_create(
directory_type=cat_type,
code=code,
defaults={
'name': cat['name'],
'is_active': True,
'parent': None,
'extra_data': {},
},
)
cat_by_code[code] = obj
cats_upserted += 1
for work in cat.get('works') or []:
wid = work['id']
extra: dict[str, Any] = {
'required_details': list(work.get('required_details') or []),
}
if work.get('notes'):
extra['notes'] = work['notes']
if work.get('source_ids'):
extra['source_ids'] = work['source_ids']
Directory.objects.update_or_create(
directory_type=work_type,
code=wid,
defaults={
'name': work['name'],
'is_active': True,
'parent': obj,
'extra_data': extra,
},
)
works_upserted += 1
return {
'categories': cats_upserted,
'works': works_upserted,
'meta_id': meta.pk,
}
def catalog_payload_for_ui() -> dict:
"""JSON для формы: категории, работы, лейблы полей."""
cats = list(
Directory.objects.filter(directory_type__code=CAT_TYPE, is_active=True)
.order_by('name')
)
works = list(
Directory.objects.filter(directory_type__code=WORK_TYPE, is_active=True)
.exclude(code='_meta')
.select_related('parent')
.order_by('parent__name', 'name')
)
meta = Directory.objects.filter(
directory_type__code=WORK_TYPE, code='_meta',
).first()
labels = {}
if meta and isinstance(meta.extra_data, dict):
labels = meta.extra_data.get('labels') or {}
if not labels and meta.extra_data.get('detail_fields'):
labels = detail_field_labels({'detail_fields': meta.extra_data['detail_fields']})
return {
'categories': [{'id': c.pk, 'code': c.code, 'name': c.name} for c in cats],
'works': [
{
'id': w.pk,
'code': w.code,
'name': w.name,
'category_id': w.parent_id,
'required_details': list((w.extra_data or {}).get('required_details') or []),
'notes': (w.extra_data or {}).get('notes') or '',
}
for w in works
],
'detail_labels': labels,
}
def required_details_for(work: Directory | None) -> list[str]:
if not work:
return []
return list((work.extra_data or {}).get('required_details') or [])
def detail_labels_from_db() -> dict[str, str]:
meta = Directory.objects.filter(
directory_type__code=WORK_TYPE, code='_meta',
).first()
if not meta or not isinstance(meta.extra_data, dict):
return {}
labels = meta.extra_data.get('labels') or {}
if labels:
return dict(labels)
if meta.extra_data.get('detail_fields'):
return detail_field_labels({'detail_fields': meta.extra_data['detail_fields']})
return {}
def parse_details_from_post(post, work: Directory | None) -> dict[str, str]:
"""Собрать details из POST (поля detail_<code>)."""
details: dict[str, str] = {}
for key in required_details_for(work):
details[key] = (post.get(f'detail_{key}') or '').strip()
return details
def validate_worklog_details(work: Directory, details: dict | None) -> str:
"""Пустая строка если ок, иначе текст ошибки."""
details = details or {}
missing = []
for key in required_details_for(work):
val = details.get(key)
if val is None or (isinstance(val, str) and not val.strip()):
missing.append(key)
if not missing:
return ''
labels = detail_labels_from_db()
named = [labels.get(k, k) for k in missing]
return 'Заполните обязательные уточнения: ' + ', '.join(named) + '.'
def format_details_short(details: dict | None, labels: dict[str, str] | None = None) -> str:
if not details:
return ''
labels = labels or {}
parts = []
for k, v in details.items():
if v is None or v == '':
continue
parts.append(f'{labels.get(k, k)}: {v}')
return '; '.join(parts)
def worklog_display_title(log, labels: dict[str, str] | None = None) -> str:
"""Заголовок для списка/отчёта."""
if getattr(log, 'catalog_work_id', None) and log.catalog_work_id:
name = log.catalog_work.name
short = format_details_short(log.details or {}, labels)
return f'{name} ({short})' if short else name
return (log.description or '').strip() or '—'
def get_catalog_work(pk) -> Directory | None:
if not pk:
return None
return (
Directory.objects.filter(
pk=pk, directory_type__code=WORK_TYPE, is_active=True,
)
.exclude(code='_meta')
.select_related('parent')
.first()
)