Files
ACORE/apps/it_manager/work_directory.py
T
lokses 4926f59f20 it_manager: полный матч импорта report-tool/data в WorkLog.
Алиасы фраз, аудит coverage, строгий import_report_tool с catalog_work и тесты.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:14:17 +03:00

244 lines
8.3 KiB
Python

"""Справочник типовых 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'
# Канонический источник для seed — в репозитории ACORE.
# Переопределение: manage.py seed_it_work_directory /path/to/work-directory.json
DEFAULT_JSON = Path(__file__).resolve().parent / 'fixtures' / 'work-directory.json'
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()
)