31bd4c8fa7
Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты. Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
8.5 KiB
Python
259 lines
8.5 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'
|
|
|
|
|
|
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()
|
|
)
|