Files
ACORE/core/models/directories.py
T
lokses ae54d93442 core: секции портала, контроль доступа и вью страниц
Section-based portal access, matrix прав по группам, аккаунтинг-портал,
управление группами пользователей, справочники, дашборд и общий каркас
навигации/стилей.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:20 +03:00

313 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import functools
from django.db import models
from treenode.models import TreeNodeModel
from core.models import BaseModel
from pytils.translit import slugify
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
def _resolve_directory_default(type_code, code):
return Directory.objects.filter(
directory_type__code=type_code, code=code,
).values_list('id', flat=True).first()
def directory_default(type_code, code):
"""Ледачий default= для ForeignKey('core.Directory') на місці колишнього
choices=CharField: резолвиться при збереженні, а не при імпорті моделі
(щоб не залежати від порядку застосування seed-міграцій). functools.partial,
а не замикання, — щоб Django міг серіалізувати default у міграціях."""
return functools.partial(_resolve_directory_default, type_code, code)
class DirectoryType(BaseModel):
code = models.CharField(max_length=50, unique=True, verbose_name=_('Код типа'))
name = models.CharField(max_length=100, verbose_name=_('Название типа'))
description = models.TextField(blank=True, verbose_name=_('Описание'))
is_active = models.BooleanField(default=True, verbose_name=_('Активный'))
# Новые поля для управления иерархией
HIERARCHY_TYPES = [
('strict', _('Строгая иерархия (одинаковые типы)')),
('flexible', _('Гибкая иерархия (разные типы)')),
('custom', _('Кастомная иерархия')),
]
hierarchy_type = models.CharField(
max_length=10,
choices=HIERARCHY_TYPES,
default='strict',
verbose_name=_('Тип иерархии')
)
allowed_child_types = models.ManyToManyField(
'self',
symmetrical=False,
blank=True,
verbose_name=_('Разрешенные типы потомков'),
help_text=_('Только для гибкой и кастомной иерархии')
)
class Meta:
verbose_name = _('Тип справочника')
verbose_name_plural = _('Типы справочников')
ordering = ['name']
def can_have_child_type(self, child_type):
"""Проверить, может ли данный тип быть потомком (для admin и валидации)."""
if self.hierarchy_type == 'strict':
return child_type == self
if self.hierarchy_type == 'flexible':
return True
if self.hierarchy_type == 'custom':
return child_type in self.allowed_child_types.all()
return False
def __str__(self):
return self.name
class DirectoryManager(models.Manager):
def get_by_type(self, type_code):
"""Получить активные записи по типу справочника"""
return self.filter(
directory_type__code=type_code,
is_active=True
).order_by('name',)
def get_choices(self, type_code):
"""Получить choices для форм"""
return self.get_by_type(type_code).values_list('id', 'name')
def get_by_code(self, type_code, item_code):
"""Получить элемент по коду типа и коду элемента. Raises DoesNotExist если не найден."""
return self.get(
directory_type__code=type_code,
code=item_code,
is_active=True
)
def with_related(self):
"""Оптимизированный queryset с select_related"""
return self.select_related('directory_type', 'parent')
def ordered(self):
"""Упорядоченный queryset"""
return self.order_by('_path',)
def for_admin(self, request):
"""Queryset для админки"""
return self.with_related().ordered()
class Directory(TreeNodeModel, BaseModel):
"""Универсальная модель для иерархических справочников"""
# Поле для отображения в дереве (treenode admin)
display_field = "name"
# Порядок соседей — priority (ручная очередность / ↑↓ в портале)
sorting_field = 'priority'
directory_type = models.ForeignKey(
DirectoryType,
on_delete=models.CASCADE,
verbose_name=_('Тип справочника')
)
code = models.CharField(max_length=100, verbose_name=_('Код'))
name = models.CharField(max_length=255, verbose_name=_('Название'))
description = models.TextField(blank=True, verbose_name=_('Описание'))
is_active = models.BooleanField(default=True, verbose_name=_('Активный'))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_('Дополнительные данные'))
location_types = models.ManyToManyField(
'self',
symmetrical=False,
blank=True,
limit_choices_to={'directory_type__code': 'location_type', 'is_active': True},
related_name='available_posts',
verbose_name=_('Типы локаций'),
help_text=_(
'Пусто = доступно для всех типов локаций. '
'Для должностей и систем доступа: в каких локациях показывать '
'(офис / аптека / склад).'
),
)
# Active Directory интеграция (для отделов)
ad_group_dn = models.CharField(
'AD Group DN',
max_length=500,
blank=True,
help_text='Distinguished Name группы в Active Directory (например: CN=IT_Department,OU=Groups,DC=company,DC=local). Только для отделов.'
)
ad_ou_dn = models.CharField(
'AD OU DN',
max_length=500,
blank=True,
help_text='Distinguished Name организационного юнита (OU) в Active Directory, куда переносятся '
'учётные записи сотрудников этого отдела (например: OU=IT,OU=Users,DC=company,DC=local). '
'Только для отделов. Если не задано — используется корневая OU коннектора AD.'
)
objects = DirectoryManager()
class Meta:
verbose_name = _('Элемент справочника')
verbose_name_plural = _('Элементы справочников')
unique_together = ['directory_type', 'code']
ordering = ['_path', 'name']
indexes = [
models.Index(fields=['directory_type', 'is_active']),
models.Index(fields=['parent', 'directory_type']),
models.Index(fields=['is_active']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.code:
self.code = slugify(self.name)
# Убедиться, что код уникален в пределах типа
if self.pk is None: # Только для новых объектов
base_code = self.code
counter = 1
while Directory.objects.filter(
directory_type=self.directory_type,
code=self.code
).exists():
self.code = f"{base_code}_{counter}"
counter += 1
return super().save(*args, **kwargs)
def clean(self):
super().clean()
if not self.parent and self.directory_type.hierarchy_type == 'custom':
if self.directory_type.allowed_child_types.exists():
raise ValidationError(
f'Тип "{self.directory_type}" с кастомной иерархией и заданными '
f'разрешёнными типами потомков не допускает корневых элементов.'
)
if self.parent:
parent_type = self.parent.directory_type
current_type = self.directory_type
# Проверка в зависимости от типа иерархии родителя
if parent_type.hierarchy_type == 'strict':
if current_type != parent_type:
raise ValidationError(
f'Для типа "{parent_type}" разрешена только строгая иерархия. '
f'Тип потомка должен быть "{parent_type}", а не "{current_type}"'
)
elif parent_type.hierarchy_type == 'custom':
if current_type not in parent_type.allowed_child_types.all():
raise ValidationError(
f'Для типа "{parent_type}" разрешены только следующие типы потомков: '
f'{", ".join(str(t) for t in parent_type.allowed_child_types.all())}'
)
# def get_queryset(self, request):
# qs = super().get_queryset(request).select_related('directory_type', 'parent')
# return qs.order_by('sort_order', '_path')
def get_full_name(self):
"""Полное название с учетом иерархии"""
return " -> ".join([node.name for node in self.get_ancestors()] + [self.name])
@property
def all_descendants_count(self):
"""Количество всех потомков"""
return self.get_descendants().count()
def get_children_count(self):
"""Безопасное получение количества дочерних элементов"""
return Directory.objects.filter(parent=self).count()
def get_siblings_same_type(self):
"""Получить соседей того же типа"""
return self.__class__.objects.filter(
directory_type=self.directory_type,
parent=self.parent
).exclude(pk=self.pk)
def get_extra_value(self, key, default=None):
"""Получить значение из extra_data по ключу"""
return self.extra_data.get(key, default)
def set_extra_value(self, key, value, save=False):
"""Установить значение в extra_data. При save=True сохраняет в БД."""
if self.extra_data is None:
self.extra_data = {}
self.extra_data[key] = value
if save:
self.save(update_fields=['extra_data', 'updated_at'])
def get_email_key(self):
"""Получить ключ для формирования email"""
return self.get_extra_value('email_key', '')
def get_short_name(self):
"""Получить короткое название"""
return self.get_extra_value('short_name', self.name)
def get_ancestors_same_type(self):
"""Получить предков того же типа (для строгой иерархии)"""
if self.directory_type.hierarchy_type != 'strict':
return self.get_ancestors()
return self.get_ancestors().filter(directory_type=self.directory_type)
def get_descendants_same_type(self):
"""Получить потомков того же типа (для строгой иерархии)"""
if self.directory_type.hierarchy_type != 'strict':
return self.get_descendants()
return self.get_descendants().filter(directory_type=self.directory_type)
def can_have_child_type(self, child_type):
"""Проверить, может ли данный тип быть потомком"""
if self.directory_type.hierarchy_type == 'strict':
return child_type == self.directory_type
elif self.directory_type.hierarchy_type == 'flexible':
return True
elif self.directory_type.hierarchy_type == 'custom':
return child_type in self.directory_type.allowed_child_types.all()
return False
@property
def allowed_child_types_list(self):
"""Список разрешенных типов потомков"""
if self.directory_type.hierarchy_type == 'strict':
return [self.directory_type]
elif self.directory_type.hierarchy_type == 'flexible':
return DirectoryType.objects.all()
elif self.directory_type.hierarchy_type == 'custom':
return self.directory_type.allowed_child_types.all()
return []
class AccessSystemManager(models.Manager):
"""Только записи справочника «Система доступу» (базы/системы для доступов)."""
def get_queryset(self):
return super().get_queryset().filter(directory_type__code='access_system')
class AccessSystem(Directory):
"""Proxy над Directory — отдельный раздел админки для управления списком
систем/баз доступа (1С, почта, CRM…), которые выбираются в заявке на найм."""
objects = AccessSystemManager()
class Meta:
proxy = True
verbose_name = 'Система доступу'
verbose_name_plural = 'Системи доступу'
# не наследуем treenode-индексы Directory (ссылаются на _path — E016 для proxy)
indexes = []
constraints = []