Перейти к содержанию

Legacy логика vs MPTT: Руководство по навигации в иерархии

Содержание

  1. Введение
  2. Историческая эволюция
  3. Проблема инверсии
  4. Подходы к навигации
  5. Где используется legacy логика
  6. Где используется MPTT напрямую
  7. Рекомендации для нового кода

1. Введение

В проекте существуют два противоположных подхода к навигации по иерархии: - Legacy логика (основная) - навигация "вниз" через поле child - MPTT логика (добавлена позже) - стандартная MPTT навигация

Критическая проблема: Из-за parent_attr="child" эти подходы дают противоположные результаты!

2. Историческая эволюция

2.1. ДО MPTT (Legacy логика)

Изначально проект использовал простую FK-навигацию:

class Level(models.Model):
    child = models.ForeignKey('Level', ...)  # Ссылка на ребёнка
    parents = models.ManyToManyField('Level', ...)  # Родители

Концепция: Иерархия строится "вниз" от родителей к детям.

Пример:

brand → product → dpu
(brand.child = product означает "product - ребёнок brand")

2.2. Добавление MPTT

Для оптимизации запросов добавили django-mptt:

class Level(MPTTModel):
    child = TreeForeignKey('Level', ...)  # Существующее поле!

    class MPTTMeta:
        parent_attr = "child"  # Используем существующее поле

Проблема: MPTT требует, чтобы FK указывал на родителя, но поле называется child!

Решение: Указали parent_attr="child", чтобы MPTT использовал это поле как ссылку на родителя.

3. Проблема инверсии

3.1. Что произошло

Из-за parent_attr="child" MPTT интерпретирует иерархию наоборот:

# Legacy логика:
brand.child = product  # "product - ребёнок brand"

# MPTT интерпретация:
brand.child = product  # "product - РОДИТЕЛЬ brand" (!)

3.2. Последствия

MPTT методы возвращают противоположное тому, что ожидается в legacy логике:

Структура (legacy):

brand → product → dpu
category → product

MPTT видит наоборот:

dpu → product → brand
         ↓
      category

Методы:

MPTT метод Что возвращает (MPTT) Что это в legacy логике
product.get_children() [brand, category] РОДИТЕЛИ (не дети!)
product.get_descendants() [brand, segment, category, ...] ПРЕДКИ (не потомки!)
brand.get_ancestors() [dpu, product] ДЕТИ/ВНУКИ (не предки!)

4. Подходы к навигации

4.1. Legacy подход (рекомендуемый)

Используйте обёртки и поля:

# ✅ Получить родителей
parents = level.get_parents_with_self()  # [self] + родители

# ✅ Получить всех предков  
ancestors = level.get_all_parents_with_self()  # [self] + все предки

# ✅ Получить ребёнка
child_level = level.child

# ✅ Получить родителей (M2M)
parent_levels = level.parents.all()

Реализация обёрток:

def get_parents_with_self(self):
    """Возвращает родителей + self (legacy логика)"""
    # Legacy логика противоположна MPTT: get_children() → родители
    parents_new = list(self.get_children())
    return [self] + parents_new

def get_all_parents_with_self(self):
    """Возвращает всех предков + self (legacy логика)"""
    # Legacy логика противоположна MPTT: get_descendants() → предки
    ancestors_new = [self] + list(self.get_descendants())
    return ancestors_new

4.2. MPTT подход (требует понимания инверсии)

Используйте MPTT методы напрямую только с явным комментарием:

# Legacy логика противоположна MPTT: get_children() возвращает родителей в legacy смысле
parent_levels = list(level.get_children())

# Legacy логика противоположна MPTT: get_descendants() возвращает предков в legacy смысле
ancestor_levels = list(level.get_descendants())

5. Где используется legacy логика

5.1. Использование обёрток get_parents_with_self()

Production код (15+ мест):

  • core/hierarchy/libs/sync.py:102, 107, 112 - синхронизация связей parents
  • core/hierarchy/libs/import_hierarchy_items.py:863, 1118, 1255 - импорт иерархии
  • core/hierarchy/libs/queries.py:193, 234, 273 - построение запросов
  • core/hierarchy/libs/generate_fake_hierarchy_items.py:105 - генерация данных
  • core/hierarchy/views.py:349 - отображение в админке
  • apps/ibp0/scripts/forecast_level_settings.py:49 - настройки прогноза
  • apps/ibp1/scripts/forecast_level_settings.py:49 - настройки прогноза

5.2. Использование обёрток get_all_parents_with_self()

Production код (10+ мест):

  • core/hierarchy/models.py:576 - метод update_ancestors()
  • core/hierarchy/models.py:584, 596, 600 - метод is_parent_for()
  • core/hierarchy/models.py:614, 637 - создание dim-таблиц
  • core/reports/libs/data_request.py:1256 - формирование отчётов
  • core/hierarchy/libs/queries.py:207 - построение запросов к dim-таблицам
  • core/filters/data_request.py:17 - фильтрация данных
  • core/filters/libs/filter.py:830 - кэширование уровней
  • core/parameters/libs/data_transfer.py:62, 64 - трансфер данных
  • core/parameters/libs/fact_manager.py:1713 - управление фактами

5.3. Использование поля child

Production код (4 места):

  • core/hierarchy/models.py:164 - построение mermaid диаграммы
  • core/hierarchy/libs/sync.py:143, 164, 174 - рекурсивная синхронизация "вниз"
# Пример из sync.py:
if with_children and level.child is not None and drop:
    # Синхронизация дочерних уровней (идём "вниз" к ребёнку)
    sync(level.child, ...)

5.4. Использование поля parents (M2M)

Production код (15+ мест):

  • core/hierarchy/libs/sync.py - работа со связями parents
  • core/hierarchy/libs/import_hierarchy_items.py - импорт родительских связей
  • core/hierarchy/libs/queries.py - построение запросов по родителям
  • И другие (см. полный список в разделе 5.1)

5.5. Legacy подход в Direct Pipeline (механизм удаления)

Файл: core/hierarchy/libs/direct/record_cleaner.py

Метод nullify_references_in_children() (строки ~320-370):

Находит все дочерние уровни (те, у кого удаляемый level в родителях) через перебор + проверку:

# ✅ Legacy подход: перебираем все уровни
all_levels = LevelModel.objects.filter(type=level.type)
child_levels = []
for potential_child in all_levels:
    parents = potential_child.get_parents_with_self()
    if level in parents and potential_child.id != level.id:
        child_levels.append(potential_child)

Почему не используются прямые ссылки: - Level.objects.filter(parents=level) - M2M parents не синхронизируется с FK child - level.mptt_parents.all() - related_name работает в обратную сторону

Метод delete_from_full_cascade() (строки ~215-300):

Находит все уровни-потомки (те, у кого удаляемый level в предках) через перебор + проверку:

# ✅ Legacy подход: перебираем все уровни
all_levels = LevelModel.objects.filter(type=level.type)
all_descendant_levels = []
for potential_descendant in all_levels:
    if potential_descendant.id == level.id:
        continue
    ancestors = potential_descendant.get_all_parents_with_self()
    if level in ancestors:
        all_descendant_levels.append(potential_descendant)

Почему не используется level.get_all_parents_with_self() напрямую: - Метод возвращает ПРЕДКОВ level, а нам нужны ПОТОМКИ - Перебор с проверкой - единственный надёжный способ в legacy логике

6. Где используется MPTT напрямую

6.1. Использование get_children() напрямую

В models.py (обёртки): - models.py:538 - внутри get_parents_with_self() с комментарием "In MPTT terms parents are children"

В Direct Pipeline (новый код): - dim_table_full.py:410, 568 - синхронизация (с комментариями о legacy логике) - item_bridge.py:194 - синхронизация с Item (с комментарием) - table_builder.py:256, 379 - построение таблиц (с комментарием) - importer.py:352 - импорт данных (с комментарием)

В legacy коде: - sync.py:631 - синхронизация уровней - admin.py:280 - админка - forms.py:109, 149, 180, 214 - формы - import_hierarchy_items.py:442, 719, 1144 - импорт - scripts/sync_level_sql.py:127 - SQL синхронизация - build_calendar.py:252 - построение календаря - И другие (всего ~28 мест)

6.2. Использование get_descendants() напрямую

В models.py (обёртки): - models.py:558 - внутри get_all_parents_with_self() с комментарием "In MPTT terms parents are children"

В legacy коде: - workflow/models.py:599 - проверка пересечений уровней - authcustom/libs/extract_from_template.py:87 - проверка пересечений

6.3. Использование get_ancestors() напрямую

В legacy коде: - workflow/models.py:620 - проверка пересечений - authcustom/libs/extract_from_template.py:104 - проверка пересечений

Примечание: В record_cleaner.py ранее использовался get_ancestors() напрямую, но был исправлен на legacy подход (см. раздел 5.5).

7. Рекомендации для нового кода

7.1. ЧТО ДЕЛАТЬ

✅ Используйте legacy подход:

# Для получения родителей
parents = level.get_parents_with_self()

# Для получения всех предков
ancestors = level.get_all_parents_with_self()

# Для получения ребёнка
child = level.child

# Для получения родителей (M2M)
parent_list = level.parents.all()

# Для синхронизации M2M ancestors
level.update_ancestors()

7.2. ЧТО НЕ ДЕЛАТЬ

❌ Не используйте MPTT методы без понимания инверсии:

# ❌ Неправильно (без комментария)
children = level.get_children()

# ❌ Неправильно (без комментария)
descendants = level.get_descendants()

7.3. ИСКЛЮЧЕНИЯ

Допустимо использовать MPTT напрямую с явным комментарием:

# ✅ Правильно (с комментарием)
# Legacy логика противоположна MPTT: get_children() возвращает родителей в legacy смысле
parent_levels = list(level.get_children())

7.4. Методы, которые работают одинаково

Эти MPTT методы можно использовать без оговорок:

# ✅ Братья/сёстры (работает одинаково)
siblings = level.get_siblings()

# ✅ Корень дерева (работает одинаково)
root = level.get_root()

# ✅ Проверки (работают одинаково)
is_leaf = level.is_leaf_node()
is_root = level.is_root_node()

7.5. Практический пример: Поиск дочерних и потомков

См. полную реализацию: src/planiqum/core/hierarchy/libs/direct/record_cleaner.py

Поиск дочерних уровней (прямые дети)

# ✅ Правильный legacy подход
all_levels = Level.objects.filter(type=level.type)
child_levels = []
for potential_child in all_levels:
    parents = potential_child.get_parents_with_self()
    if level in parents and potential_child.id != level.id:
        child_levels.append(potential_child)

Почему перебор: - M2M parents не синхронизируется с FK child автоматически - MPTT методы инвертированы - related_name от FK работает наоборот

Поиск всех потомков (прямые и непрямые)

# ✅ Правильный legacy подход
all_levels = Level.objects.filter(type=level.type)
all_descendant_levels = []
for potential_descendant in all_levels:
    if potential_descendant.id == level.id:
        continue
    ancestors = potential_descendant.get_all_parents_with_self()
    if level in ancestors:
        all_descendant_levels.append(potential_descendant)

Пример использования:

Для иерархии segment → brand → product → dpu: - Поиск детей brand: вернёт [product] - Поиск потомков brand: вернёт [product, dpu]

8. Примеры

8.1. Пример: Получение всех предков

# ✅ Legacy подход (рекомендуемый)
ancestors = brand.get_all_parents_with_self()
# Вернёт: [brand, product, dpu]

# ❌ MPTT подход (неправильно без понимания)
descendants = brand.get_descendants()
# Вернёт: [] (т.к. brand - листовой в MPTT!)

# ✅ MPTT с пониманием инверсии
# Legacy логика противоположна MPTT: get_ancestors() в MPTT = потомки в legacy
mptt_ancestors = brand.get_ancestors()
# Вернёт: [dpu, product] - это "дети" в legacy логике!

8.2. Пример: Получение родителей

# ✅ Legacy подход (рекомендуемый)
parents = product.get_parents_with_self()
# Вернёт: [product, brand, category]

# ❌ MPTT подход (неправильно без понимания)
children = product.get_children()
# Вернёт: [brand, category] - но это РОДИТЕЛИ в legacy логике!

# ✅ MPTT с пониманием инверсии и комментарием
# Legacy логика противоположна MPTT: get_children() возвращает родителей
mptt_children = list(product.get_children())
# Вернёт: [brand, category] - родители в legacy логике

9. Тесты и шпаргалки

См. полные тесты: - src/planiqum/core/hierarchy/tests/test_mptt_and_legacy_navigation.py - тесты MPTT методов и legacy обёрток - src/planiqum/core/hierarchy/tests/direct/test_cascade_deletion.py - тесты удаления с legacy подходом (практический пример)

Практические примеры: - src/planiqum/core/hierarchy/libs/direct/record_cleaner.py - поиск дочерних и потомков через legacy подход

Быстрая справка: - .cursor/rules/hierarchy.mdc - правила для AI и разработчиков

10. Чек-лист для code review

При проверке кода с иерархией убедитесь:

  • [ ] Используется legacy подход (get_parents_with_self(), get_all_parents_with_self())
  • [ ] Если используются MPTT методы напрямую - есть явный комментарий о инверсии
  • [ ] Поиск дочерних/потомков реализован через перебор + проверку
  • [ ] Нет прямых обращений к Level.objects.filter(parents=level)
  • [ ] Нет использования level.mptt_parents.all() для поиска детей
  • [ ] В тестах есть ASCII-диаграмма иерархии

Важно: описанные настройки и сценарии могут отличаться в вашей инсталляции Planiqum
За уточнениями и методологической поддержкой обращайтесь в компанию ЮНИК СОФТ