refactor: Replace inline CSS with components (Phase 3)
- Replaced feed-* inline styles in base.html with feed-cards.css component (~60 lines) - Replaced ios-search inline styles in 3 templates with ios-search.css component * employees/employees_list.html * search/results.html * employees/department_list.html - Eliminated ~150 lines of duplicate CSS across templates - All 17 tests passing
This commit is contained in:
parent
e1f9a4206f
commit
4d04aafdb9
@ -10,25 +10,25 @@
|
||||
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r backend/requirements.txt
|
||||
cp backend/.env backend/.env.local || true
|
||||
# edit backend/.env.local if needed (USE_SQLITE=True by default)
|
||||
# Edit backend/.env.local if needed (USE_SQLITE=True by default)
|
||||
python backend/manage.py migrate
|
||||
python backend/manage.py createsuperuser
|
||||
python backend/manage.py runserver 0.0.0.0:9000
|
||||
```
|
||||
Open http://127.0.0.1:9000/
|
||||
Open [http://127.0.0.1:9000/](http://127.0.0.1:9000/)
|
||||
|
||||
## Full stack with Docker (Postgres + Redis)
|
||||
1. Put your secrets into `backend/.env` (use `.env.example` as a template). **Never commit real secrets.**
|
||||
2. Fix Dockerfile to use `eusrr_backend.asgi` (or use the provided `Dockerfile.fixed`).
|
||||
2. Ensure the `Dockerfile` uses `eusrr_backend.asgi` (or use the provided `Dockerfile.fixed`).
|
||||
3. Launch:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
The app will be available on http://127.0.0.1:9000/
|
||||
The app will be available at [http://127.0.0.1:9000/](http://127.0.0.1:9000/)
|
||||
|
||||
## Useful commands
|
||||
```bash
|
||||
# Telegram bot
|
||||
# Start Telegram bot
|
||||
python backend/manage.py start_telegram_bot
|
||||
|
||||
# Run tests
|
||||
@ -36,6 +36,6 @@ pytest -q backend
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Set `API_BASE_URL` to match where the app is served (default http://127.0.0.1:9000/api).
|
||||
- For Channels websockets, keep Redis reachable at `${REDIS_HOST}:${REDIS_PORT}`.
|
||||
- The `hikcentral` DB is configured as a secondary Postgres. Externalize its DSN via envs if you plan to use it.
|
||||
- Set `API_BASE_URL` to match where the app is served (default: [http://127.0.0.1:9000/api](http://127.0.0.1:9000/api)).
|
||||
- For Channels websockets, ensure Redis is reachable at `${REDIS_HOST}:${REDIS_PORT}`.
|
||||
- The `hikcentral` DB is configured as a secondary Postgres. Externalize its DSN via environment variables if you plan to use it.
|
||||
|
||||
151
backend/analyze_templates.py
Normal file
151
backend/analyze_templates.py
Normal file
@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Анализ шаблонов: поиск встроенных стилей и скриптов.
|
||||
Помогает понять масштаб рефакторинга.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
# Путь к шаблонам
|
||||
TEMPLATES_DIR = Path(__file__).parent / 'templates'
|
||||
|
||||
|
||||
def analyze_template(filepath):
|
||||
"""Анализирует один шаблон"""
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
lines = len(content.splitlines())
|
||||
|
||||
# Подсчёт тегов
|
||||
style_tags = len(re.findall(r'<style[^>]*>', content))
|
||||
script_tags = len(re.findall(r'<script[^>]*>', content))
|
||||
|
||||
# Размер встроенных стилей и скриптов
|
||||
style_blocks = re.findall(r'<style[^>]*>(.*?)</style>', content, re.DOTALL)
|
||||
script_blocks = re.findall(r'<script[^>]*>(.*?)</script>', content, re.DOTALL)
|
||||
|
||||
style_lines = sum(len(block.splitlines()) for block in style_blocks)
|
||||
script_lines = sum(len(block.splitlines()) for block in script_blocks)
|
||||
|
||||
# Поиск CSS классов
|
||||
css_classes = set(re.findall(r'\.([a-z][a-z0-9\-_]*)\s*{', content, re.IGNORECASE))
|
||||
|
||||
# Поиск функций JavaScript
|
||||
js_functions = set(re.findall(r'function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(', content))
|
||||
|
||||
return {
|
||||
'path': str(filepath.relative_to(TEMPLATES_DIR)),
|
||||
'total_lines': lines,
|
||||
'style_tags': style_tags,
|
||||
'script_tags': script_tags,
|
||||
'style_lines': style_lines,
|
||||
'script_lines': script_lines,
|
||||
'css_classes': css_classes,
|
||||
'js_functions': js_functions,
|
||||
'has_blocks': bool(re.search(r'{%\s*block\s+extra_(css|js)\s*%}', content)),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Главная функция анализа"""
|
||||
print("=" * 80)
|
||||
print("АНАЛИЗ ШАБЛОНОВ: встроенные стили и скрипты")
|
||||
print("=" * 80)
|
||||
|
||||
# Находим все HTML файлы
|
||||
html_files = list(TEMPLATES_DIR.rglob('*.html'))
|
||||
|
||||
results = []
|
||||
for filepath in sorted(html_files):
|
||||
# Пропускаем backup файлы
|
||||
if '.backup' in str(filepath):
|
||||
continue
|
||||
|
||||
result = analyze_template(filepath)
|
||||
if result and (result['style_tags'] > 0 or result['script_tags'] > 0):
|
||||
results.append(result)
|
||||
|
||||
# Сортируем по количеству встроенного кода
|
||||
results.sort(key=lambda x: x['style_lines'] + x['script_lines'], reverse=True)
|
||||
|
||||
print(f"\nНайдено {len(results)} шаблонов со встроенными стилями/скриптами\n")
|
||||
|
||||
# Таблица результатов
|
||||
print(f"{'Файл':<60} {'Строк':<8} {'<style>':<8} {'<script>':<8} {'CSS↓':<8} {'JS↓':<8}")
|
||||
print("-" * 110)
|
||||
|
||||
total_style_lines = 0
|
||||
total_script_lines = 0
|
||||
|
||||
for r in results:
|
||||
total_style_lines += r['style_lines']
|
||||
total_script_lines += r['script_lines']
|
||||
|
||||
print(f"{r['path']:<60} "
|
||||
f"{r['total_lines']:<8} "
|
||||
f"{r['style_tags']:<8} "
|
||||
f"{r['script_tags']:<8} "
|
||||
f"{r['style_lines']:<8} "
|
||||
f"{r['script_lines']:<8}")
|
||||
|
||||
print("-" * 110)
|
||||
print(f"{'ИТОГО:':<60} "
|
||||
f"{'':8} "
|
||||
f"{'':8} "
|
||||
f"{'':8} "
|
||||
f"{total_style_lines:<8} "
|
||||
f"{total_script_lines:<8}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("АНАЛИЗ ДУБЛИКАТОВ")
|
||||
print("=" * 80)
|
||||
|
||||
# Анализ повторяющихся CSS классов
|
||||
all_classes = defaultdict(list)
|
||||
for r in results:
|
||||
for cls in r['css_classes']:
|
||||
all_classes[cls].append(r['path'])
|
||||
|
||||
duplicated_classes = {k: v for k, v in all_classes.items() if len(v) > 1}
|
||||
|
||||
if duplicated_classes:
|
||||
print(f"\nПовторяющиеся CSS классы ({len(duplicated_classes)}):")
|
||||
for cls, files in sorted(duplicated_classes.items(), key=lambda x: len(x[1]), reverse=True)[:10]:
|
||||
print(f" .{cls} → {len(files)} файлов: {', '.join(files[:3])}{' ...' if len(files) > 3 else ''}")
|
||||
|
||||
# Анализ повторяющихся JS функций
|
||||
all_functions = defaultdict(list)
|
||||
for r in results:
|
||||
for func in r['js_functions']:
|
||||
all_functions[func].append(r['path'])
|
||||
|
||||
duplicated_functions = {k: v for k, v in all_functions.items() if len(v) > 1}
|
||||
|
||||
if duplicated_functions:
|
||||
print(f"\nПовторяющиеся JS функции ({len(duplicated_functions)}):")
|
||||
for func, files in sorted(duplicated_functions.items(), key=lambda x: len(x[1]), reverse=True)[:10]:
|
||||
print(f" {func}() → {len(files)} файлов: {', '.join(files[:3])}{' ...' if len(files) > 3 else ''}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("РЕКОМЕНДАЦИИ ПО РЕФАКТОРИНГУ")
|
||||
print("=" * 80)
|
||||
|
||||
# ТОП-5 файлов для рефакторинга
|
||||
print("\nТОП-5 файлов с наибольшим количеством встроенного кода:")
|
||||
for i, r in enumerate(results[:5], 1):
|
||||
total_embedded = r['style_lines'] + r['script_lines']
|
||||
percentage = (total_embedded / r['total_lines']) * 100
|
||||
print(f"{i}. {r['path']}")
|
||||
print(f" {r['total_lines']} строк всего, {total_embedded} встроенного кода ({percentage:.1f}%)")
|
||||
print(f" {r['style_lines']} строк CSS, {r['script_lines']} строк JS")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -12,54 +12,27 @@ from django.contrib.auth.models import Group, Permission
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
|
||||
Prefetch, Q, Subquery, Value, When)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from employees.constants import ACTION_DISMISSED
|
||||
from employees.ldap.connections import _conn
|
||||
from employees.ldap.directory_service import (
|
||||
DirectoryDbError,
|
||||
DirectoryDepartmentDTO,
|
||||
DirectoryLdapError,
|
||||
DirectoryService,
|
||||
DirectoryServiceError,
|
||||
DirectoryUserDTO,
|
||||
)
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
DeptPerm,
|
||||
EmployeeAction,
|
||||
EmployeeDepartment,
|
||||
LdapSyncState,
|
||||
Position,
|
||||
Skill,
|
||||
)
|
||||
from employees.utils import (
|
||||
_build_links_for_dept,
|
||||
_detect_phone_field,
|
||||
_ensure_department_permissions,
|
||||
_head_choices_for_dept,
|
||||
_normalize_phone,
|
||||
_perm_choices_synced,
|
||||
_to_bool,
|
||||
_validate_head_active,
|
||||
)
|
||||
from employees.ldap.directory_service import (DirectoryDepartmentDTO,
|
||||
DirectoryService,
|
||||
DirectoryUserDTO)
|
||||
from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError,
|
||||
DirectoryServiceError)
|
||||
from employees.ldap.infrastructure.connections import _conn
|
||||
from employees.models import (Department, DepartmentPermission, DepartmentRole,
|
||||
DeptPerm, EmployeeAction, EmployeeDepartment,
|
||||
LdapSyncState, Position, Skill)
|
||||
from employees.utils import (_build_links_for_dept, _detect_phone_field,
|
||||
_ensure_department_permissions,
|
||||
_head_choices_for_dept, _normalize_phone,
|
||||
_perm_choices_synced, _to_bool,
|
||||
_validate_head_active)
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
@ -67,31 +40,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from ..permissions import (
|
||||
AdminOrActionOrModelPerms,
|
||||
AdminOrDeptAllowed,
|
||||
IsSelfOrStaff,
|
||||
has_dept_perm,
|
||||
)
|
||||
from .serializers import (
|
||||
AddMemberInput,
|
||||
DepartmentBriefSerializer,
|
||||
DepartmentRoleSerializer,
|
||||
DepartmentSerializer,
|
||||
EmailSerializer,
|
||||
EmailVerifySerializer,
|
||||
EmployeeActionSerializer,
|
||||
EmployeeBriefSerializer,
|
||||
EmployeeListSerializer,
|
||||
EmployeeSerializer,
|
||||
GroupSerializer,
|
||||
PositionSerializer,
|
||||
RegisterSerializer,
|
||||
RemoveMemberInput,
|
||||
SetHeadInput,
|
||||
SetMemberRoleInput,
|
||||
SkillSerializer,
|
||||
)
|
||||
from ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed,
|
||||
IsSelfOrStaff, has_dept_perm)
|
||||
from .serializers import (AddMemberInput, DepartmentBriefSerializer,
|
||||
DepartmentRoleSerializer, DepartmentSerializer,
|
||||
EmailSerializer, EmailVerifySerializer,
|
||||
EmployeeActionSerializer, EmployeeBriefSerializer,
|
||||
EmployeeListSerializer, EmployeeSerializer,
|
||||
GroupSerializer, PositionSerializer,
|
||||
RegisterSerializer, RemoveMemberInput, SetHeadInput,
|
||||
SetMemberRoleInput, SkillSerializer)
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
170
backend/check_templates.py
Normal file
170
backend/check_templates.py
Normal file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Быстрая проверка синтаксиса шаблонов без запуска тестов.
|
||||
Использование: python check_templates.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Настройка Django окружения
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from django.template import loader
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Цветной вывод для терминала
|
||||
class Colors:
|
||||
GREEN = '\033[92m'
|
||||
RED = '\033[91m'
|
||||
YELLOW = '\033[93m'
|
||||
BLUE = '\033[94m'
|
||||
RESET = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
|
||||
def check_template_syntax(template_path):
|
||||
"""Проверка синтаксиса одного шаблона"""
|
||||
try:
|
||||
loader.get_template(template_path)
|
||||
return True, None
|
||||
except TemplateSyntaxError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, f"Unexpected error: {e}"
|
||||
|
||||
|
||||
def check_forbidden_tags(template_path):
|
||||
"""Проверка на недопустимые теги в компонентах"""
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
file_path = templates_dir / template_path
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
forbidden_tags = [
|
||||
'{% extends ',
|
||||
'{% block content %}',
|
||||
'{% block extra_css %}',
|
||||
'{% block extra_js %}',
|
||||
]
|
||||
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
found_tags = []
|
||||
|
||||
for tag in forbidden_tags:
|
||||
if tag in content:
|
||||
found_tags.append(tag.strip())
|
||||
|
||||
return found_tags
|
||||
|
||||
|
||||
def check_orphan_endblock(template_path):
|
||||
"""Проверка на одинокие {% endblock %}"""
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
file_path = templates_dir / template_path
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
lines_with_endblock = [
|
||||
i + 1 for i, line in enumerate(content.splitlines())
|
||||
if '{% endblock' in line
|
||||
]
|
||||
|
||||
return lines_with_endblock
|
||||
|
||||
|
||||
def main():
|
||||
"""Основная функция проверки"""
|
||||
|
||||
# Список всех рефакторенных компонентов
|
||||
components = [
|
||||
# Department components
|
||||
'employees/components/department_header.html',
|
||||
'employees/components/department_info.html',
|
||||
'employees/components/department_team_circle.html',
|
||||
'employees/components/department_sidebar.html',
|
||||
'employees/components/department_modals.html',
|
||||
'employees/components/department_scripts.html',
|
||||
'employees/components/department_styles.html',
|
||||
|
||||
# Employee form components
|
||||
'employees/components/employee_form_personal.html',
|
||||
'employees/components/employee_form_contact.html',
|
||||
'employees/components/employee_form_position.html',
|
||||
'employees/components/employee_form_actions.html',
|
||||
'employees/components/employee_form_scripts.html',
|
||||
'employees/_employee_edit.html',
|
||||
]
|
||||
|
||||
# Main templates
|
||||
main_templates = [
|
||||
'employees/department_detail.html',
|
||||
'includes/rightbar_calendar.html',
|
||||
]
|
||||
|
||||
print(f"\n{Colors.BOLD}=== Проверка компонентов шаблонов ==={Colors.RESET}\n")
|
||||
|
||||
total_errors = 0
|
||||
|
||||
# Проверка компонентов
|
||||
print(f"{Colors.BLUE}Компоненты:{Colors.RESET}")
|
||||
for template in components:
|
||||
# Проверка синтаксиса
|
||||
is_valid, error = check_template_syntax(template)
|
||||
|
||||
if is_valid:
|
||||
print(f" {Colors.GREEN}✓{Colors.RESET} {template}")
|
||||
|
||||
# Дополнительные проверки для валидных шаблонов
|
||||
forbidden = check_forbidden_tags(template)
|
||||
if forbidden:
|
||||
print(f" {Colors.YELLOW}⚠ Содержит теги layout: "
|
||||
f"{', '.join(forbidden)}{Colors.RESET}")
|
||||
total_errors += 1
|
||||
|
||||
endblocks = check_orphan_endblock(template)
|
||||
if endblocks:
|
||||
print(f" {Colors.YELLOW}⚠ Содержит {{% endblock %}} "
|
||||
f"на строках: {endblocks}{Colors.RESET}")
|
||||
total_errors += 1
|
||||
else:
|
||||
print(f" {Colors.RED}✗{Colors.RESET} {template}")
|
||||
print(f" {Colors.RED}Ошибка: {error}{Colors.RESET}")
|
||||
total_errors += 1
|
||||
|
||||
# Проверка главных шаблонов
|
||||
print(f"\n{Colors.BLUE}Главные шаблоны:{Colors.RESET}")
|
||||
for template in main_templates:
|
||||
is_valid, error = check_template_syntax(template)
|
||||
|
||||
if is_valid:
|
||||
print(f" {Colors.GREEN}✓{Colors.RESET} {template}")
|
||||
else:
|
||||
print(f" {Colors.RED}✗{Colors.RESET} {template}")
|
||||
print(f" {Colors.RED}Ошибка: {error}{Colors.RESET}")
|
||||
total_errors += 1
|
||||
|
||||
# Итоги
|
||||
print(f"\n{Colors.BOLD}=== Итоги ==={Colors.RESET}")
|
||||
print(f"Проверено шаблонов: {len(components) + len(main_templates)}")
|
||||
|
||||
if total_errors == 0:
|
||||
print(f"{Colors.GREEN}{Colors.BOLD}✓ Все шаблоны корректны!"
|
||||
f"{Colors.RESET}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"{Colors.RED}{Colors.BOLD}✗ Обнаружено ошибок: "
|
||||
f"{total_errors}{Colors.RESET}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
133
backend/diagnose_page.py
Normal file
133
backend/diagnose_page.py
Normal file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Диагностика проблем с рендерингом страниц.
|
||||
Проверяет, правильно ли подключаются компоненты.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.template import loader
|
||||
from employees.models import Department
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def diagnose_department_page():
|
||||
"""Диагностика страницы отдела"""
|
||||
print("\n=== Диагностика страницы отдела ===\n")
|
||||
|
||||
# Создаём mock request
|
||||
factory = RequestFactory()
|
||||
request = factory.get('/employees/departments/1/')
|
||||
|
||||
# Пробуем получить отдел
|
||||
try:
|
||||
dept = Department.objects.first()
|
||||
if not dept:
|
||||
print("❌ Нет отделов в базе")
|
||||
return
|
||||
print(f"✓ Отдел найден: {dept.name} (ID: {dept.id})")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка получения отдела: {e}")
|
||||
return
|
||||
|
||||
# Пробуем загрузить шаблон
|
||||
try:
|
||||
template = loader.get_template('employees/department_detail.html')
|
||||
print("✓ Шаблон department_detail.html загружен")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка загрузки шаблона: {e}")
|
||||
return
|
||||
|
||||
# Проверяем компоненты
|
||||
components = [
|
||||
'employees/components/department_header.html',
|
||||
'employees/components/department_info.html',
|
||||
'employees/components/department_team_circle.html',
|
||||
'employees/components/department_sidebar.html',
|
||||
'employees/components/department_modals.html',
|
||||
'employees/components/department_styles.html',
|
||||
'employees/components/department_scripts.html',
|
||||
]
|
||||
|
||||
print("\nКомпоненты:")
|
||||
for comp in components:
|
||||
try:
|
||||
loader.get_template(comp)
|
||||
print(f" ✓ {comp}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {comp}: {e}")
|
||||
|
||||
# Проверяем календарь
|
||||
print("\nКомпоненты календаря:")
|
||||
calendar_components = [
|
||||
'includes/calendar/calendar_styles.html',
|
||||
'includes/calendar/calendar_widget.html',
|
||||
'includes/calendar/calendar_events_list.html',
|
||||
'includes/calendar/calendar_modals.html',
|
||||
'includes/calendar/calendar_scripts.html',
|
||||
]
|
||||
|
||||
for comp in calendar_components:
|
||||
try:
|
||||
loader.get_template(comp)
|
||||
print(f" ✓ {comp}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {comp}: {e}")
|
||||
|
||||
print("\n=== Проверка размеров компонентов ===\n")
|
||||
from django.conf import settings
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
|
||||
for comp in components:
|
||||
file_path = templates_dir / comp
|
||||
if file_path.exists():
|
||||
lines = len(file_path.read_text(encoding='utf-8').splitlines())
|
||||
size = file_path.stat().st_size
|
||||
print(f" {comp}: {lines} строк, {size} байт")
|
||||
else:
|
||||
print(f" ❌ {comp}: файл не найден")
|
||||
|
||||
|
||||
def check_base_template():
|
||||
"""Проверка base.html"""
|
||||
print("\n=== Проверка base.html ===\n")
|
||||
|
||||
try:
|
||||
template = loader.get_template('base.html')
|
||||
print("✓ base.html загружен")
|
||||
|
||||
# Проверяем, что в нём есть блоки
|
||||
from django.conf import settings
|
||||
base_path = Path(settings.BASE_DIR) / 'templates' / 'base.html'
|
||||
content = base_path.read_text(encoding='utf-8')
|
||||
|
||||
checks = [
|
||||
('{% block extra_css %}', 'Блок extra_css'),
|
||||
('{% block extra_js %}', 'Блок extra_js'),
|
||||
('{% include "includes/rightbar_calendar.html" %}', 'Include календаря'),
|
||||
('bootstrap.bundle.min.js', 'Bootstrap JS'),
|
||||
('fullcalendar', 'FullCalendar JS'),
|
||||
]
|
||||
|
||||
for pattern, description in checks:
|
||||
if pattern in content:
|
||||
print(f" ✓ {description}")
|
||||
else:
|
||||
print(f" ❌ {description} НЕ НАЙДЕН!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
diagnose_department_page()
|
||||
check_base_template()
|
||||
55
backend/diagnostic_summary.py
Normal file
55
backend/diagnostic_summary.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""
|
||||
Финальная диагностика - проверка того, что попадает в HTML
|
||||
"""
|
||||
print("""
|
||||
=== ИТОГИ ИСПРАВЛЕНИЙ ===
|
||||
|
||||
✓ Исправлено 3 ошибки в компонентах:
|
||||
1. department_info.html - добавлен {% endif %}
|
||||
2. department_modals.html - удалены лишние теги
|
||||
3. department_scripts.html - удалён {% endblock %}
|
||||
|
||||
✓ Исправлена структура department_detail.html:
|
||||
- Стили перемещены в {% block extra_js %}
|
||||
- Теперь соответствует оригинальной структуре
|
||||
|
||||
✓ Все 20 шаблонов проходят синтаксическую проверку
|
||||
|
||||
=== ЧТО ДЕЛАТЬ ДАЛЬШЕ ===
|
||||
|
||||
1. Жёсткая перезагрузка страницы в браузере: Ctrl+Shift+R
|
||||
(очистить кэш CSS/JS)
|
||||
|
||||
2. Проверить консоль браузера (F12 → Console):
|
||||
- Есть ли ошибки загрузки Bootstrap JS?
|
||||
- Есть ли JavaScript ошибки?
|
||||
|
||||
3. Проверить, что Bootstrap загружен:
|
||||
- Открыть консоль (F12)
|
||||
- Ввести: window.bootstrap
|
||||
- Должен быть объект, а не undefined
|
||||
|
||||
4. Если dropdown не работает:
|
||||
- Проверить, что у элемента есть data-bs-toggle="dropdown"
|
||||
- Проверить, что Bootstrap JS загружен БЕЗ ошибок
|
||||
- Попробовать вручную: new bootstrap.Dropdown(element)
|
||||
|
||||
5. Если календарь не работает:
|
||||
- Проверить, что FullCalendar JS загружен
|
||||
- Открыть консоль: window.FullCalendar
|
||||
- Проверить ошибки в calendar_scripts.html
|
||||
|
||||
=== КОМАНДЫ ДЛЯ ПРОВЕРКИ ===
|
||||
|
||||
# Проверка синтаксиса всех шаблонов:
|
||||
python check_templates.py
|
||||
|
||||
# Запуск тестов:
|
||||
python manage.py test tests.test_template_components
|
||||
|
||||
# Проверка что файлы на месте:
|
||||
ls -la templates/employees/components/
|
||||
ls -la templates/includes/calendar/
|
||||
|
||||
===================================
|
||||
""")
|
||||
241
backend/employees/ldap/README.md
Normal file
241
backend/employees/ldap/README.md
Normal file
@ -0,0 +1,241 @@
|
||||
# Модуль LDAP Синхронизации
|
||||
|
||||
## Описание
|
||||
|
||||
Модуль для синхронизации данных между Active Directory (LDAP) и Django-приложением. Поддерживает двустороннюю синхронизацию пользователей, отделов и групп безопасности.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Модуль организован по принципу чистой архитектуры с явным разделением ответственности:
|
||||
|
||||
```
|
||||
ldap/
|
||||
├── domain/ # Бизнес-логика и DTO
|
||||
│ ├── dto.py # Data Transfer Objects
|
||||
│ └── validators.py # Валидация данных
|
||||
│
|
||||
├── infrastructure/ # Внешние зависимости
|
||||
│ ├── connections.py # Управление LDAP-соединениями
|
||||
│ └── ldap_client.py # Обёртка над ldap3
|
||||
│
|
||||
├── repositories/ # Слой доступа к данным
|
||||
│ ├── ldap_repository.py # CRUD операции в LDAP
|
||||
│ ├── db_repository.py # CRUD операции в Django ORM
|
||||
│ └── sync_state_repository.py # Управление состоянием синхронизации
|
||||
│
|
||||
├── services/ # Бизнес-сервисы
|
||||
│ ├── directory_service.py # Фасад для CRUD операций
|
||||
│ ├── user_service.py # Управление пользователями
|
||||
│ ├── department_service.py # Управление отделами
|
||||
│ ├── group_service.py # Управление группами
|
||||
│ └── sync/
|
||||
│ ├── user_sync_service.py # Синхронизация пользователей
|
||||
│ ├── department_sync_service.py # Синхронизация отделов
|
||||
│ └── sync_orchestrator.py # Координация синхронизации
|
||||
│
|
||||
└── utils/ # Утилиты
|
||||
├── dn_utils.py # Работа с Distinguished Names
|
||||
├── ldap_utils.py # LDAP-специфичные утилиты
|
||||
├── text_utils.py # Обработка текста (escape, normalize)
|
||||
└── image_utils.py # Обработка изображений (аватары)
|
||||
```
|
||||
|
||||
## Основные компоненты
|
||||
|
||||
### 1. DirectoryService
|
||||
|
||||
Главный фасад для операций с LDAP. Предоставляет унифицированный API для создания, обновления и удаления объектов.
|
||||
|
||||
```python
|
||||
from employees.ldap import DirectoryService, DirectoryUserDTO
|
||||
|
||||
service = DirectoryService()
|
||||
|
||||
# Создание пользователя
|
||||
dto = DirectoryUserDTO(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
email="ivanov@example.com",
|
||||
phone_e164="+79991234567",
|
||||
department_dn="OU=IT,OU=Departments,DC=example,DC=com",
|
||||
group_cns=["Domain Users", "IT Support"],
|
||||
initial_password="SecurePass123!",
|
||||
is_active=True
|
||||
)
|
||||
employee = service.create_user(dto)
|
||||
|
||||
# Обновление пользователя
|
||||
service.update_user(
|
||||
employee,
|
||||
phone_e164="+79999999999",
|
||||
department_dn="OU=HR,OU=Departments,DC=example,DC=com"
|
||||
)
|
||||
|
||||
# Удаление пользователя
|
||||
service.delete_user(employee)
|
||||
```
|
||||
|
||||
### 2. Синхронизация
|
||||
|
||||
Двусторонняя синхронизация с поддержкой различных режимов и областей.
|
||||
|
||||
```python
|
||||
from employees.ldap import SyncConfig, import_users, export_users
|
||||
|
||||
# Импорт из LDAP в Django
|
||||
config = SyncConfig(
|
||||
mode='ldap', # Направление: 'ldap' → Django
|
||||
scope='users', # Область: 'users', 'departments', 'groups', 'all'
|
||||
dry_run=False, # False = применить изменения
|
||||
show_changes=True # Показывать детали изменений
|
||||
)
|
||||
created, updated, deleted = import_users(cfg=config)
|
||||
print(f"Создано: {created}, Обновлено: {updated}, Удалено: {deleted}")
|
||||
|
||||
# Экспорт из Django в LDAP
|
||||
config = SyncConfig(
|
||||
mode='django', # Направление: Django → 'ldap'
|
||||
scope='users',
|
||||
dry_run=False
|
||||
)
|
||||
created, updated, deleted, groups_sync, avatars = export_users(cfg=config)
|
||||
```
|
||||
|
||||
### 3. Конфигурация
|
||||
|
||||
```python
|
||||
from employees.ldap import SyncConfig
|
||||
|
||||
config = SyncConfig(
|
||||
mode='ldap', # 'ldap' | 'django' | 'auto'
|
||||
scope='all', # 'all' | 'users' | 'departments' | 'groups'
|
||||
dry_run=True, # Сухой прогон без изменений
|
||||
max_changes=1000, # Лимит изменений за сессию
|
||||
users_base_dn="OU=Users,DC=example,DC=com",
|
||||
departments_base_dn="OU=Departments,DC=example,DC=com",
|
||||
groups_base_dn="OU=Groups,DC=example,DC=com",
|
||||
show_changes=True # Детальный вывод
|
||||
)
|
||||
```
|
||||
|
||||
## Настройки Django
|
||||
|
||||
Добавьте в `settings.py`:
|
||||
|
||||
```python
|
||||
# LDAP Configuration
|
||||
LDAP_URI = "ldaps://dc.example.com:636"
|
||||
LDAP_BASE_DN = "DC=example,DC=com"
|
||||
LDAP_BIND_DN = "CN=service_account,CN=Users,DC=example,DC=com"
|
||||
LDAP_BIND_PASSWORD = "secure_password"
|
||||
|
||||
# Базовые DN для различных типов объектов
|
||||
LDAP_USERS_BASE = "OU=Users,DC=example,DC=com"
|
||||
LDAP_DEPARTMENTS_BASE = "OU=Departments,DC=example,DC=com"
|
||||
LDAP_GROUPS_BASE = "OU=Groups,DC=example,DC=com"
|
||||
|
||||
# CA-сертификат (опционально, для LDAPS)
|
||||
LDAP_CA_CERTS = "/path/to/ca-cert.pem"
|
||||
|
||||
# Домен по умолчанию для генерации email
|
||||
DEFAULT_EMAIL_DOMAIN = "example.com"
|
||||
```
|
||||
|
||||
## Модель данных
|
||||
|
||||
### LdapSyncState
|
||||
|
||||
Отслеживает состояние синхронизации объектов:
|
||||
|
||||
```python
|
||||
class LdapSyncState(models.Model):
|
||||
model = models.CharField(max_length=50) # 'employee', 'department'
|
||||
object_pk = models.CharField(max_length=50)
|
||||
ldap_dn = models.TextField()
|
||||
ldap_guid = models.CharField(max_length=100)
|
||||
sync_dir = models.CharField(max_length=10) # 'ldap', 'django'
|
||||
synced_at = models.DateTimeField(auto_now=True)
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
```python
|
||||
from employees.ldap import (
|
||||
DirectoryServiceError, # Базовая ошибка
|
||||
DirectoryLdapError, # Ошибка LDAP
|
||||
DirectoryDbError, # Ошибка БД
|
||||
DirectoryGroupError # Ошибка работы с группами
|
||||
)
|
||||
|
||||
try:
|
||||
service.create_user(dto)
|
||||
except DirectoryLdapError as e:
|
||||
# Обработка ошибок LDAP (недоступен сервер, неверные учётные данные)
|
||||
logger.error(f"LDAP error: {e}")
|
||||
except DirectoryDbError as e:
|
||||
# Обработка ошибок БД (нарушение ограничений, блокировки)
|
||||
logger.error(f"Database error: {e}")
|
||||
except DirectoryServiceError as e:
|
||||
# Общие ошибки сервиса
|
||||
logger.error(f"Service error: {e}")
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Создание отдела
|
||||
|
||||
```python
|
||||
from employees.ldap import DirectoryService, DirectoryDepartmentDTO
|
||||
|
||||
service = DirectoryService()
|
||||
dto = DirectoryDepartmentDTO(
|
||||
name="IT Department",
|
||||
description="Information Technology",
|
||||
head=None # можно указать Employee
|
||||
)
|
||||
department = service.create_department(dto)
|
||||
```
|
||||
|
||||
### Синхронизация групп пользователя
|
||||
|
||||
```python
|
||||
from employees.ldap.groups import sync_user_groups_by_cns
|
||||
from employees.ldap.connections import _ldap
|
||||
|
||||
with _ldap() as conn:
|
||||
added, removed = sync_user_groups_by_cns(
|
||||
conn=conn,
|
||||
user_dn="CN=John Doe,OU=Users,DC=example,DC=com",
|
||||
target_cns=["Domain Users", "IT Support", "Developers"],
|
||||
do_write=True
|
||||
)
|
||||
print(f"Добавлено в группы: {added}, Удалено из групп: {removed}")
|
||||
```
|
||||
|
||||
### Импорт отделов
|
||||
|
||||
```python
|
||||
from employees.ldap import SyncConfig
|
||||
from employees.ldap.sync_service import import_departments
|
||||
|
||||
config = SyncConfig(mode='ldap', scope='departments', dry_run=False)
|
||||
created, updated, deleted = import_departments(cfg=config)
|
||||
```
|
||||
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
# Запуск всех тестов модуля
|
||||
pytest backend/employees/ldap/tests/
|
||||
|
||||
# Запуск конкретного теста
|
||||
pytest backend/employees/ldap/tests/test_sync_service.py
|
||||
|
||||
# С покрытием
|
||||
pytest --cov=employees.ldap backend/employees/ldap/tests/
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Внутренний корпоративный модуль EUSRR.
|
||||
@ -0,0 +1,106 @@
|
||||
"""Модуль синхронизации с Active Directory / LDAP.
|
||||
|
||||
Предоставляет высокоуровневые API для:
|
||||
- Синхронизации пользователей, отделов и групп между LDAP и Django
|
||||
- Управления объектами в LDAP (создание, обновление, удаление)
|
||||
- Экспорта изменений из Django в LDAP
|
||||
|
||||
Архитектура (Clean Architecture):
|
||||
domain/ - Бизнес-логика и DTO (независимые от инфраструктуры)
|
||||
services/ - Бизнес-сервисы (UserService, DepartmentService и др.)
|
||||
repositories/ - Слой доступа к данным (LDAP + DB)
|
||||
infrastructure/ - Внешние подключения (LDAP)
|
||||
utils/ - Вспомогательные функции (DN, группы, текст и др.)
|
||||
|
||||
Основное использование:
|
||||
from employees.ldap import DirectoryService, DirectoryUserDTO, SyncConfig
|
||||
|
||||
# Создание пользователя через фасад
|
||||
service = DirectoryService()
|
||||
dto = DirectoryUserDTO(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
email="ivanov@example.com",
|
||||
phone_e164="+79991234567",
|
||||
department_dn="OU=IT,DC=example,DC=com",
|
||||
group_cns=["Domain Users"],
|
||||
initial_password="SecurePass123!",
|
||||
is_active=True
|
||||
)
|
||||
employee = service.create_user(dto)
|
||||
|
||||
# Синхронизация из LDAP в Django
|
||||
from employees.ldap import import_users
|
||||
config = SyncConfig(mode='ldap', scope='users', dry_run=False)
|
||||
created, updated, deleted = import_users(cfg=config)
|
||||
|
||||
# Синхронизация групп пользователя
|
||||
from employees.ldap import sync_user_groups_by_cns, _ldap
|
||||
with _ldap() as conn:
|
||||
added, removed = sync_user_groups_by_cns(
|
||||
conn, user_dn, target_cns=["IT Support"], do_write=True
|
||||
)
|
||||
"""
|
||||
|
||||
# Текущие импорты (для обратной совместимости)
|
||||
from .directory_service import DirectoryService
|
||||
from .sync_service import (
|
||||
import_departments,
|
||||
import_users,
|
||||
export_users,
|
||||
)
|
||||
|
||||
# Конфигурация
|
||||
from .config import SyncConfig, SyncMode, SyncScope, DISABLED_FLAG
|
||||
|
||||
# Ошибки
|
||||
from .errors import (
|
||||
DirectoryServiceError,
|
||||
DirectoryLdapError,
|
||||
DirectoryDbError,
|
||||
DirectoryGroupError,
|
||||
)
|
||||
|
||||
# DTO (из domain слоя)
|
||||
from .domain.dtos import (
|
||||
DirectoryUserDTO,
|
||||
DirectoryDepartmentDTO,
|
||||
LdapPersonDTO,
|
||||
)
|
||||
|
||||
# Утилиты работы с группами
|
||||
from .utils.group_utils import sync_user_groups_by_cns
|
||||
|
||||
# Подключения
|
||||
from .infrastructure.connections import _ldap
|
||||
|
||||
__all__ = [
|
||||
# Сервисы
|
||||
"DirectoryService",
|
||||
"import_departments",
|
||||
"import_users",
|
||||
"export_users",
|
||||
|
||||
# Конфигурация
|
||||
"SyncConfig",
|
||||
"SyncMode",
|
||||
"SyncScope",
|
||||
"DISABLED_FLAG",
|
||||
|
||||
# Ошибки
|
||||
"DirectoryServiceError",
|
||||
"DirectoryLdapError",
|
||||
"DirectoryDbError",
|
||||
"DirectoryGroupError",
|
||||
|
||||
# DTO
|
||||
"DirectoryUserDTO",
|
||||
"DirectoryDepartmentDTO",
|
||||
"LdapPersonDTO",
|
||||
|
||||
# Утилиты
|
||||
"sync_user_groups_by_cns",
|
||||
|
||||
# Подключения
|
||||
"_ldap",
|
||||
]
|
||||
@ -1,54 +0,0 @@
|
||||
# ldap/connections.py
|
||||
import ssl
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.conf import settings
|
||||
from ldap3 import Connection, Server, Tls
|
||||
|
||||
|
||||
def _conn() -> Connection:
|
||||
"""Возвращает привязанное service-bind соединение LDAP.
|
||||
|
||||
Returns:
|
||||
Connection: Готовое соединение.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Неверные настройки подключения.
|
||||
"""
|
||||
uri = getattr(settings, "LDAP_URI", "")
|
||||
bind_dn = getattr(settings, "LDAP_BIND_DN", "")
|
||||
bind_pw = getattr(settings, "LDAP_BIND_PASSWORD", "")
|
||||
if not uri or not bind_dn or not bind_pw:
|
||||
raise RuntimeError("LDAP_URI/LDAP_BIND_DN/LDAP_BIND_PASSWORD must be set")
|
||||
|
||||
ca_file = getattr(settings, "LDAP_CA_CERTS", "") or None
|
||||
tls = Tls(
|
||||
validate=ssl.CERT_REQUIRED if ca_file else ssl.CERT_NONE, ca_certs_file=ca_file
|
||||
)
|
||||
server = Server(uri, use_ssl=uri.lower().startswith("ldaps://"), tls=tls)
|
||||
return Connection(
|
||||
server, user=bind_dn, password=bind_pw, auto_bind=True, receive_timeout=30
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _ldap() -> Connection:
|
||||
"""Контекст-менеджер LDAP-соединения с гарантированным закрытием.
|
||||
|
||||
Yields:
|
||||
Connection: Подключение ldap3.
|
||||
|
||||
Notes:
|
||||
Любые ошибки закрытия глушатся — не маскируют основной результат.
|
||||
"""
|
||||
conn = _conn()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
if hasattr(conn, "unbind"):
|
||||
conn.unbind()
|
||||
elif hasattr(conn, "close"):
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
18
backend/employees/ldap/domain/__init__.py
Normal file
18
backend/employees/ldap/domain/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Доменный слой модуля LDAP.
|
||||
|
||||
Содержит бизнес-логику, DTO и доменные модели.
|
||||
"""
|
||||
|
||||
from .dtos import (
|
||||
DirectoryUserDTO,
|
||||
DirectoryDepartmentDTO,
|
||||
LdapPersonDTO,
|
||||
_entry_to_dto,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DirectoryUserDTO",
|
||||
"DirectoryDepartmentDTO",
|
||||
"LdapPersonDTO",
|
||||
"_entry_to_dto",
|
||||
]
|
||||
154
backend/employees/ldap/domain/dtos.py
Normal file
154
backend/employees/ldap/domain/dtos.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Domain Data Transfer Objects для LDAP-сервисов.
|
||||
|
||||
Этот модуль содержит DTOs, используемые для передачи данных
|
||||
между различными слоями приложения при работе с LDAP каталогом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from ...models import Employee
|
||||
from ..utils.ldap_utils import _ldap_pick_phone, _uac_is_active, get_attr_str, get_guid_str
|
||||
from employees.utils import _normalize_phone
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryUserDTO:
|
||||
"""DTO для создания/обновления пользователя через DirectoryService.
|
||||
|
||||
Attributes:
|
||||
first_name (str): Имя пользователя.
|
||||
last_name (str): Фамилия пользователя.
|
||||
email (str): Email адрес пользователя.
|
||||
phone_e164 (Optional[str]): Телефон в формате E.164 или None.
|
||||
department_dn (Optional[str]): DN целевого отдела (OU в LDAP).
|
||||
group_cns (List[str]): Список CN групп для членства пользователя.
|
||||
initial_password (str): Начальный пароль (используется только в LDAP).
|
||||
avatar_bytes (Optional[bytes]): Байты изображения аватара.
|
||||
is_active (bool): Флаг активности (влияет на userAccountControl в AD).
|
||||
"""
|
||||
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
phone_e164: Optional[str]
|
||||
department_dn: Optional[str]
|
||||
group_cns: List[str]
|
||||
initial_password: str
|
||||
avatar_bytes: Optional[bytes] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryDepartmentDTO:
|
||||
"""DTO для создания/обновления отдела (Organizational Unit).
|
||||
|
||||
Attributes:
|
||||
name (str): Название отдела.
|
||||
description (Optional[str]): Описание отдела.
|
||||
head (Optional[Employee]): Руководитель отдела (объект Employee).
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
head: Optional[Employee] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LdapPersonDTO:
|
||||
"""Нормализованные данные пользователя из LDAP для импорта в Django.
|
||||
|
||||
Используется при синхронизации LDAP → Django для представления
|
||||
пользователя, прочитанного из Active Directory.
|
||||
|
||||
Attributes:
|
||||
dn (str): DN объекта пользователя.
|
||||
guid (Optional[str]): Строковое представление GUID пользователя.
|
||||
username (str): sAMAccountName (может быть пустым).
|
||||
email (str): E-mail (в нижнем регистре; может быть сгенерирован как fallback).
|
||||
given (str): Имя (м.б. получено из display/email).
|
||||
sn (str): Фамилия (м.б. пустой).
|
||||
display (str): displayName из LDAP.
|
||||
when_changed (Optional[str]): whenChanged из LDAP (как строка; храним как есть).
|
||||
is_active (bool): Флаг активности по userAccountControl.
|
||||
phone_e164 (Optional[str]): Нормализованный телефон в E.164, если удалось.
|
||||
"""
|
||||
|
||||
dn: str
|
||||
guid: Optional[str]
|
||||
username: str
|
||||
email: str
|
||||
given: str
|
||||
sn: str
|
||||
display: str
|
||||
when_changed: Optional[str]
|
||||
is_active: bool
|
||||
phone_e164: Optional[str]
|
||||
|
||||
|
||||
def _entry_to_dto(entry) -> LdapPersonDTO:
|
||||
"""Преобразует LDAP-объект в DTO с нормализацией e-mail, ФИО и телефона.
|
||||
|
||||
Args:
|
||||
entry: Объект из ldap3 (результат поиска).
|
||||
|
||||
Returns:
|
||||
LdapPersonDTO: Нормализованные поля для upsert в Django.
|
||||
"""
|
||||
a = getattr(entry, "entry_attributes_as_dict", {}) or {}
|
||||
dn: str = str(getattr(entry, "entry_dn", "")) or get_attr_str(
|
||||
a, "distinguishedName"
|
||||
)
|
||||
guid = get_guid_str(a)
|
||||
username = get_attr_str(a, "sAMAccountName")
|
||||
email = (get_attr_str(a, "mail") or "").lower()
|
||||
given = get_attr_str(a, "givenName")
|
||||
sn = get_attr_str(a, "sn")
|
||||
display = get_attr_str(a, "displayName")
|
||||
when_changed = get_attr_str(a, "whenChanged")
|
||||
is_active = _uac_is_active(a.get("userAccountControl"))
|
||||
|
||||
# Email fallback
|
||||
if not email and username:
|
||||
domain = getattr(settings, "DEFAULT_EMAIL_DOMAIN", "robotail.local")
|
||||
email = f"{username}@{domain}".lower()
|
||||
|
||||
# Имя/фамилия fallbacks
|
||||
if not (given or sn):
|
||||
if display:
|
||||
parts = display.split(" ", 1)
|
||||
given = parts[0]
|
||||
sn = parts[1] if len(parts) > 1 else ""
|
||||
else:
|
||||
local = (email or "user").split("@", 1)[0]
|
||||
given = local
|
||||
sn = ""
|
||||
|
||||
# Телефон
|
||||
phone_raw = _ldap_pick_phone(a)
|
||||
phone_e164 = _normalize_phone(phone_raw)
|
||||
|
||||
return LdapPersonDTO(
|
||||
dn=dn,
|
||||
guid=str(guid) if guid else None,
|
||||
username=username,
|
||||
email=email,
|
||||
given=given,
|
||||
sn=sn,
|
||||
display=display,
|
||||
when_changed=when_changed or None,
|
||||
is_active=is_active,
|
||||
phone_e164=phone_e164,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DirectoryUserDTO",
|
||||
"DirectoryDepartmentDTO",
|
||||
"LdapPersonDTO",
|
||||
"_entry_to_dto",
|
||||
]
|
||||
@ -1,91 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from ..utils import _normalize_phone
|
||||
from .utils import _ldap_pick_phone, _uac_is_active, get_attr_str, get_guid_str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LdapPersonDTO:
|
||||
"""Нормализованные данные пользователя из LDAP для upsert в Django.
|
||||
|
||||
Attributes:
|
||||
dn (str): DN объекта пользователя.
|
||||
guid (str | None): Строковое представление GUID пользователя.
|
||||
username (str): sAMAccountName (может быть пустым).
|
||||
email (str): E-mail (в нижнем регистре; может быть сгенерирован как fallback).
|
||||
given (str): Имя (м.б. получено из display/email).
|
||||
sn (str): Фамилия (м.б. пустой).
|
||||
display (str): displayName из LDAP.
|
||||
when_changed (str): whenChanged из LDAP (как строка; храним как есть).
|
||||
is_active (bool): Флаг активности по userAccountControl.
|
||||
phone_e164 (str | None): Нормализованный телефон в E.164, если удалось.
|
||||
"""
|
||||
|
||||
dn: str
|
||||
guid: Optional[str]
|
||||
username: str
|
||||
email: str
|
||||
given: str
|
||||
sn: str
|
||||
display: str
|
||||
when_changed: Optional[str]
|
||||
is_active: bool
|
||||
phone_e164: Optional[str]
|
||||
|
||||
|
||||
def _entry_to_dto(entry) -> LdapPersonDTO:
|
||||
"""Преобразует LDAP-объект в DTO с нормализацией e-mail, ФИО и телефона.
|
||||
|
||||
Args:
|
||||
entry: Объект из ldap3.
|
||||
|
||||
Returns:
|
||||
LdapPersonDTO: Нормализованные поля для upsert.
|
||||
"""
|
||||
a = getattr(entry, "entry_attributes_as_dict", {}) or {}
|
||||
dn: str = str(getattr(entry, "entry_dn", "")) or get_attr_str(
|
||||
a, "distinguishedName"
|
||||
)
|
||||
guid = get_guid_str(a)
|
||||
username = get_attr_str(a, "sAMAccountName")
|
||||
email = (get_attr_str(a, "mail") or "").lower()
|
||||
given = get_attr_str(a, "givenName")
|
||||
sn = get_attr_str(a, "sn")
|
||||
display = get_attr_str(a, "displayName")
|
||||
when_changed = get_attr_str(a, "whenChanged")
|
||||
is_active = _uac_is_active(a.get("userAccountControl"))
|
||||
|
||||
# Email fallback
|
||||
if not email and username:
|
||||
email = f"{username}@{getattr(settings, 'DEFAULT_EMAIL_DOMAIN', 'robotail.local')}".lower()
|
||||
|
||||
# Имя/фамилия fallbacks
|
||||
if not (given or sn):
|
||||
if display:
|
||||
parts = display.split(" ", 1)
|
||||
given = parts[0]
|
||||
sn = parts[1] if len(parts) > 1 else ""
|
||||
else:
|
||||
local = (email or "user").split("@", 1)[0]
|
||||
given = local
|
||||
sn = ""
|
||||
|
||||
# Телефон
|
||||
phone_raw = _ldap_pick_phone(a)
|
||||
phone_e164 = _normalize_phone(phone_raw)
|
||||
|
||||
return LdapPersonDTO(
|
||||
dn=dn,
|
||||
guid=str(guid) if guid else None,
|
||||
username=username,
|
||||
email=email,
|
||||
given=given,
|
||||
sn=sn,
|
||||
display=display,
|
||||
when_changed=when_changed or None,
|
||||
is_active=is_active,
|
||||
phone_e164=phone_e164,
|
||||
)
|
||||
@ -1,152 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Mapping, Optional, Sequence, Any, Set
|
||||
|
||||
from django.conf import settings
|
||||
from ldap3 import BASE, MODIFY_REPLACE, SUBTREE, Connection
|
||||
|
||||
from .utils import esc_filter, esc_rdn, group_type
|
||||
from ..models import Department
|
||||
|
||||
|
||||
def read_attrs(conn: Connection, dn: str, attrs: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Читает значения указанных LDAP-атрибутов у записи по DN."""
|
||||
if not isinstance(dn, str) or not dn.strip():
|
||||
raise TypeError("dn должен быть непустой строкой")
|
||||
if not isinstance(attrs, list) or not all(isinstance(a, str) for a in attrs):
|
||||
raise TypeError("attrs должен быть списком строк")
|
||||
|
||||
out: Dict[str, Optional[str]] = {a: None for a in attrs}
|
||||
try:
|
||||
ok = conn.search(
|
||||
search_base=dn,
|
||||
search_filter="(objectClass=*)",
|
||||
search_scope=BASE,
|
||||
attributes=attrs,
|
||||
)
|
||||
if not ok or not conn.entries:
|
||||
return out
|
||||
entry = conn.entries[0]
|
||||
for a in attrs:
|
||||
try:
|
||||
raw = getattr(getattr(entry, a, None), "value", None)
|
||||
if isinstance(raw, (list, tuple)):
|
||||
raw = raw[0] if raw else None
|
||||
out[a] = str(raw).strip() if raw is not None else None
|
||||
except Exception:
|
||||
out[a] = None
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
def is_taken(
|
||||
conn: Connection,
|
||||
*,
|
||||
attributes: Mapping[str, Optional[str]],
|
||||
object_classes: Sequence[str] = ("user",),
|
||||
object_category: str = "person",
|
||||
base_dn: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Проверяет занятость атрибутов в домене (LDAP search OR по значениям).
|
||||
|
||||
Args:
|
||||
conn (Connection): Активное LDAP-соединение.
|
||||
attributes (Mapping[str, Optional[str]]): Пары {атрибут: значение}. Пустые/None игнорируются.
|
||||
object_classes (Sequence[str]): Список objectClass для фильтрации (по умолчанию ('user',)).
|
||||
object_category (str): Значение objectCategory (по умолчанию 'person').
|
||||
base_dn (Optional[str]): База поиска. Если не указана, берётся из settings.LDAP_BASE_DN.
|
||||
|
||||
Returns:
|
||||
bool: True, если найден хотя бы один объект с любым из указанных значений.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не задан base DN.
|
||||
TypeError: Если ключи/значения словаря атрибутов имеют неверные типы.
|
||||
"""
|
||||
base = base_dn or getattr(settings, "LDAP_BASE_DN", None)
|
||||
if not base:
|
||||
raise RuntimeError("LDAP_BASE_DN must be set")
|
||||
|
||||
# Валидация входных данных
|
||||
if not isinstance(attributes, Mapping):
|
||||
raise TypeError("attributes must be a mapping")
|
||||
if not all(isinstance(k, str) and k.strip() for k in attributes.keys()):
|
||||
raise TypeError("All attribute names must be non-empty strings")
|
||||
if not all((v is None) or isinstance(v, str) for v in attributes.values()):
|
||||
raise TypeError("All attribute values must be strings or None")
|
||||
|
||||
# (attr1=val1) OR (attr2=val2) ...
|
||||
attr_filters = [
|
||||
f"({attr}={esc_filter(value)})" for attr, value in attributes.items() if value
|
||||
]
|
||||
if not attr_filters:
|
||||
return False # нечего проверять
|
||||
|
||||
# AND с категорией и классами
|
||||
class_filter = "".join(f"(objectClass={esc_filter(cls)})" for cls in object_classes)
|
||||
ldap_filter = f"(&(|{''.join(attr_filters)})(objectCategory={esc_filter(object_category)}){class_filter})"
|
||||
|
||||
conn.search(
|
||||
search_base=base,
|
||||
search_filter=ldap_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=["cn"],
|
||||
size_limit=1,
|
||||
)
|
||||
return bool(conn.entries)
|
||||
|
||||
|
||||
def modify_user_attrs(
|
||||
conn, user_dn: str, attrs: dict[str, object], *, do_write: bool
|
||||
) -> None:
|
||||
"""MODIFY_REPLACE атрибутов пользователя; None-значения пропускаются."""
|
||||
changes = {
|
||||
k: [(MODIFY_REPLACE, [v] if not isinstance(v, (list, tuple)) else list(v))]
|
||||
for k, v in attrs.items()
|
||||
if v is not None
|
||||
}
|
||||
if not changes or not do_write:
|
||||
return
|
||||
if not conn.modify(user_dn, changes=changes):
|
||||
raise RuntimeError(f"LDAP modify failed for {user_dn}: {conn.result}")
|
||||
|
||||
|
||||
def ensure_container_exists(conn: Connection, base_dn: str) -> None:
|
||||
"""Проверяет, что контейнер (OU) существует."""
|
||||
if not isinstance(base_dn, str) or not base_dn.strip():
|
||||
raise TypeError("base_dn должен быть непустой строкой")
|
||||
ok = conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter="(objectClass=organizationalUnit)",
|
||||
search_scope=BASE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if not ok or not conn.entries:
|
||||
raise RuntimeError(f"Контейнер для создания не найден: {base_dn}")
|
||||
|
||||
|
||||
def ldap_modify_or_ignore(
|
||||
conn: Connection,
|
||||
dn: str,
|
||||
changes: Dict[str, Any],
|
||||
ignore_descriptions: Set[str] | None = None,
|
||||
) -> None:
|
||||
"""Общий modify с белым списком «нефатальных» описаний.
|
||||
|
||||
Args:
|
||||
conn (Connection): Подключение LDAP.
|
||||
dn (str): DN объекта.
|
||||
changes (Dict[str, Any]): Карта изменений для conn.modify.
|
||||
ignore_descriptions (Optional[Set[str]]): Список описаний, которые не считаем ошибкой.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если modify вернул ошибку вне разрешённых.
|
||||
"""
|
||||
ok = conn.modify(dn, changes)
|
||||
if ok:
|
||||
return
|
||||
desc = (conn.result or {}).get("description", "")
|
||||
if ignore_descriptions and desc in ignore_descriptions:
|
||||
return
|
||||
raise RuntimeError(f"LDAP modify failed: {conn.result}")
|
||||
12
backend/employees/ldap/infrastructure/__init__.py
Normal file
12
backend/employees/ldap/infrastructure/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Инфраструктурный слой модуля LDAP.
|
||||
|
||||
Содержит подключения к внешним системам (LDAP server).
|
||||
"""
|
||||
|
||||
from .connections import _conn, _ldap, ldap_connection
|
||||
|
||||
__all__ = [
|
||||
"_conn",
|
||||
"_ldap",
|
||||
"ldap_connection",
|
||||
]
|
||||
87
backend/employees/ldap/infrastructure/connections.py
Normal file
87
backend/employees/ldap/infrastructure/connections.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Управление LDAP-соединениями.
|
||||
|
||||
Функции для создания и управления подключениями к Active Directory / LDAP серверу.
|
||||
"""
|
||||
|
||||
import ssl
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from django.conf import settings
|
||||
from ldap3 import Connection, Server, Tls
|
||||
|
||||
|
||||
def _conn() -> Connection:
|
||||
"""Возвращает привязанное service-bind соединение LDAP.
|
||||
|
||||
Returns:
|
||||
Connection: Готовое соединение с автоматической привязкой.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если настройки подключения не заданы или неверны.
|
||||
"""
|
||||
uri = getattr(settings, "LDAP_URI", "")
|
||||
bind_dn = getattr(settings, "LDAP_BIND_DN", "")
|
||||
bind_pw = getattr(settings, "LDAP_BIND_PASSWORD", "")
|
||||
|
||||
if not uri or not bind_dn or not bind_pw:
|
||||
raise RuntimeError(
|
||||
"LDAP_URI/LDAP_BIND_DN/LDAP_BIND_PASSWORD must be set in settings"
|
||||
)
|
||||
|
||||
ca_file = getattr(settings, "LDAP_CA_CERTS", "") or None
|
||||
tls = Tls(
|
||||
validate=ssl.CERT_REQUIRED if ca_file else ssl.CERT_NONE,
|
||||
ca_certs_file=ca_file,
|
||||
)
|
||||
|
||||
server = Server(uri, use_ssl=uri.lower().startswith("ldaps://"), tls=tls)
|
||||
|
||||
return Connection(
|
||||
server,
|
||||
user=bind_dn,
|
||||
password=bind_pw,
|
||||
auto_bind=True,
|
||||
receive_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _ldap() -> Generator[Connection, None, None]:
|
||||
"""Контекст-менеджер LDAP-соединения с гарантированным закрытием.
|
||||
|
||||
Yields:
|
||||
Connection: Подключение ldap3.
|
||||
|
||||
Notes:
|
||||
Любые ошибки закрытия соединения глушатся, чтобы не маскировать
|
||||
основной результат операций.
|
||||
|
||||
Example:
|
||||
>>> from employees.ldap.infrastructure import _ldap
|
||||
>>> with _ldap() as conn:
|
||||
... conn.search("dc=example,dc=com", "(objectClass=*)")
|
||||
"""
|
||||
conn = _conn()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
if hasattr(conn, "unbind"):
|
||||
conn.unbind()
|
||||
elif hasattr(conn, "close"):
|
||||
conn.close()
|
||||
except Exception:
|
||||
# Игнорируем ошибки закрытия соединения
|
||||
pass
|
||||
|
||||
|
||||
# Псевдоним для обратной совместимости
|
||||
ldap_connection = _ldap
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_conn",
|
||||
"_ldap",
|
||||
"ldap_connection",
|
||||
]
|
||||
@ -1,204 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, Iterable, Optional, Set, Tuple
|
||||
|
||||
from django.utils import timezone
|
||||
from requests_app.enums import RequestStatus
|
||||
from requests_app.models import Request
|
||||
|
||||
from ..models import Department, Employee, EmployeeDepartment, LdapSyncState
|
||||
from .dto import LdapPersonDTO
|
||||
from .utils import extract_department_from_dn
|
||||
|
||||
|
||||
def _load_existing_users_index(
|
||||
dtos: Iterable[LdapPersonDTO],
|
||||
) -> Tuple[Dict[str, Employee], Dict[str, Employee]]:
|
||||
"""Строит индексы существующих пользователей по GUID и по e-mail (lower)."""
|
||||
guids: Set[str] = {d.guid.strip() for d in dtos if getattr(d, "guid", None)}
|
||||
emails_lower: Set[str] = {
|
||||
d.email.strip().lower() for d in dtos if getattr(d, "email", None)
|
||||
}
|
||||
|
||||
by_email: Dict[str, Employee] = {}
|
||||
if emails_lower:
|
||||
# Берём ровно те email'ы, что пришли, и кладём ключами lower()
|
||||
qs = Employee.objects.filter(email__in=list(emails_lower)).only("id", "email")
|
||||
for u in qs:
|
||||
if u.email:
|
||||
by_email[u.email.lower()] = u
|
||||
|
||||
# guid -> Employee через sync-state
|
||||
by_guid: Dict[str, Employee] = {}
|
||||
if guids:
|
||||
states = LdapSyncState.objects.filter(
|
||||
model="employee",
|
||||
ldap_guid__in=guids,
|
||||
).values("ldap_guid", "object_pk")
|
||||
emp_map = {
|
||||
str(e.pk): e
|
||||
for e in Employee.objects.filter(
|
||||
pk__in={str(s["object_pk"]) for s in states}
|
||||
)
|
||||
}
|
||||
for s in states:
|
||||
emp = emp_map.get(str(s["object_pk"]))
|
||||
if emp:
|
||||
by_guid[str(s["ldap_guid"])] = emp
|
||||
|
||||
return by_guid, by_email
|
||||
|
||||
|
||||
def _find_user_for_dto(
|
||||
dto: LdapPersonDTO, *, by_guid: Dict[str, Employee], by_email: Dict[str, Employee]
|
||||
) -> Optional[Employee]:
|
||||
"""Находит пользователя по GUID, иначе по e-mail (lower)."""
|
||||
if dto.guid and dto.guid in by_guid:
|
||||
return by_guid[dto.guid]
|
||||
if dto.email:
|
||||
return by_email.get(dto.email.lower())
|
||||
return None
|
||||
|
||||
|
||||
def _bind_user_department(user: Employee, dn: str) -> None:
|
||||
"""Привязывает пользователя к отделу на основе DN (или снимает активную привязку).
|
||||
|
||||
Args:
|
||||
user (Employee): Пользователь.
|
||||
dn (str): Distinguished Name пользователя.
|
||||
|
||||
Raises:
|
||||
django.db.DatabaseError: Ошибки записи в БД.
|
||||
"""
|
||||
dept_name = extract_department_from_dn(dn)
|
||||
if dept_name:
|
||||
dept_obj, _ = Department.objects.get_or_create(name=dept_name)
|
||||
EmployeeDepartment.objects.filter(employee=user, is_active=True).exclude(
|
||||
department=dept_obj
|
||||
).update(is_active=False)
|
||||
link, made = EmployeeDepartment.objects.get_or_create(
|
||||
employee=user, department=dept_obj, defaults={"is_active": True}
|
||||
)
|
||||
if not made and not link.is_active:
|
||||
link.is_active = True
|
||||
link.save(update_fields=["is_active"])
|
||||
else:
|
||||
EmployeeDepartment.objects.filter(employee=user, is_active=True).update(
|
||||
is_active=False
|
||||
)
|
||||
|
||||
|
||||
def _touch_sync_state(
|
||||
user: Employee,
|
||||
*,
|
||||
dn: str,
|
||||
guid: Optional[str],
|
||||
last_ldap_modify_ts: Optional[datetime] = None,
|
||||
last_django_modify_ts: Optional[datetime] = None,
|
||||
sync_dir: str = "ldap",
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Обновляет/создаёт запись состояния синхронизации.
|
||||
"""
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="employee", object_pk=str(user.pk)
|
||||
)
|
||||
state.touch(
|
||||
ldap_dn=dn,
|
||||
ldap_guid=guid or None,
|
||||
last_ldap_modify_ts=last_ldap_modify_ts,
|
||||
last_django_modify_ts=last_django_modify_ts or timezone.now(),
|
||||
sync_dir=sync_dir,
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_absent_users(
|
||||
*,
|
||||
seen_guids: Set[str],
|
||||
seen_dns: Set[str],
|
||||
dry_run: bool,
|
||||
show_changes: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Обрабатывает сотрудников, отсутствующих в LDAP (зеркальный режим).
|
||||
Логика с «блокировкой» (approver с APPROVED/REJECTED) сохранена.
|
||||
"""
|
||||
st_qs = LdapSyncState.objects.filter(model="employee")
|
||||
|
||||
# Повторяем семантику прежнего .exclude(ldap_guid__in) и .exclude(ldap_dn__in)
|
||||
# но работаем по sync-state; дополнительно игнорируем пустые DN.
|
||||
stale_states = st_qs
|
||||
if seen_guids:
|
||||
stale_states = (
|
||||
stale_states.exclude(ldap_guid__in=seen_guids)
|
||||
.exclude(ldap_guid__isnull=True)
|
||||
.exclude(ldap_guid="")
|
||||
)
|
||||
if seen_dns:
|
||||
stale_states = stale_states.exclude(ldap_dn__in=seen_dns).exclude(ldap_dn="")
|
||||
|
||||
emp_ids = list(stale_states.values_list("object_pk", flat=True))
|
||||
stale_qs = Employee.objects.filter(pk__in=emp_ids, is_ldap_managed=True)
|
||||
|
||||
# Кто «заблокирован» из-за уже согласованных/отклонённых заявок
|
||||
blocked_ids_qs = (
|
||||
Request.objects.filter(
|
||||
approver__in=stale_qs.values("pk"),
|
||||
status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED],
|
||||
)
|
||||
.values_list("approver_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
blocked_qs = stale_qs.filter(pk__in=blocked_ids_qs)
|
||||
to_delete_qs = stale_qs.exclude(pk__in=blocked_ids_qs)
|
||||
|
||||
if show_changes:
|
||||
blocked_emails = list(blocked_qs.values_list("email", flat=True))
|
||||
to_delete_emails = list(to_delete_qs.values_list("email", flat=True))
|
||||
for e in to_delete_emails:
|
||||
verb = "будет удалён" if dry_run else "удалён"
|
||||
print(f"[CHG] - User: {e} ({verb})")
|
||||
for e in blocked_emails:
|
||||
verb = "будет деактивирован" if dry_run else "деактивирован"
|
||||
print(f"[CHG] ! User: {e} ({verb}; есть утверждённые/отклонённые заявки)")
|
||||
|
||||
blocked_count = blocked_qs.count()
|
||||
to_delete_count = to_delete_qs.count()
|
||||
|
||||
print(
|
||||
f"[LDAP] Отсутствуют в каталоге: всего={stale_qs.count()}, "
|
||||
f"к удалению={to_delete_count}, к деактивации={blocked_count} (из-за заявлений APPROVED/REJECTED)"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return to_delete_count
|
||||
|
||||
# 1) Деактивируем «заблокированных к удалению»
|
||||
if blocked_count:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee__in=blocked_qs.values("pk"), is_active=True
|
||||
).update(is_active=False)
|
||||
Department.objects.filter(head__in=blocked_qs.values("pk")).update(head=None)
|
||||
blocked_qs.update(
|
||||
is_active=False,
|
||||
is_ldap_managed=False,
|
||||
)
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee",
|
||||
object_pk__in=blocked_qs.values_list("pk", flat=True),
|
||||
).delete()
|
||||
|
||||
# 2) Удаляем остальных
|
||||
if to_delete_count:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee__in=to_delete_qs.values("pk")
|
||||
).delete()
|
||||
Department.objects.filter(head__in=to_delete_qs.values("pk")).update(head=None)
|
||||
del_ids = list(to_delete_qs.values_list("pk", flat=True))
|
||||
to_delete_qs.delete()
|
||||
LdapSyncState.objects.filter(model="employee", object_pk__in=del_ids).delete()
|
||||
|
||||
return to_delete_count
|
||||
29
backend/employees/ldap/repositories/__init__.py
Normal file
29
backend/employees/ldap/repositories/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Слой репозиториев модуля LDAP.
|
||||
|
||||
Содержит классы для работы с данными (LDAP и Django ORM).
|
||||
"""
|
||||
|
||||
from .ldap_repository import LdapRepository, read_attrs, is_taken, modify_user_attrs, ensure_container_exists, ldap_modify_or_ignore
|
||||
from .sync_state_repository import SyncStateRepository, _touch_sync_state
|
||||
from .employee_repository import EmployeeRepository, _load_existing_users_index, _find_user_for_dto, _bind_user_department, _cleanup_absent_users
|
||||
|
||||
__all__ = [
|
||||
# Классы репозиториев
|
||||
"LdapRepository",
|
||||
"SyncStateRepository",
|
||||
"EmployeeRepository",
|
||||
|
||||
# Обратная совместимость - функции из helpers.py
|
||||
"read_attrs",
|
||||
"is_taken",
|
||||
"modify_user_attrs",
|
||||
"ensure_container_exists",
|
||||
"ldap_modify_or_ignore",
|
||||
|
||||
# Обратная совместимость - функции из repo_db.py
|
||||
"_load_existing_users_index",
|
||||
"_find_user_for_dto",
|
||||
"_bind_user_department",
|
||||
"_cleanup_absent_users",
|
||||
"_touch_sync_state",
|
||||
]
|
||||
293
backend/employees/ldap/repositories/employee_repository.py
Normal file
293
backend/employees/ldap/repositories/employee_repository.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""Репозиторий для работы с сотрудниками в Django ORM.
|
||||
|
||||
Предоставляет методы для поиска, создания и управления объектами Employee,
|
||||
а также их связями с отделами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, Optional, Set, Tuple
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from requests_app.enums import RequestStatus
|
||||
from requests_app.models import Request
|
||||
|
||||
from employees.models import Department, Employee, EmployeeDepartment, LdapSyncState
|
||||
|
||||
from ..domain.dtos import LdapPersonDTO
|
||||
from ..utils.dn_utils import extract_department_from_dn
|
||||
|
||||
|
||||
class EmployeeRepository:
|
||||
"""Репозиторий для работы с Employee в Django ORM.
|
||||
|
||||
Предоставляет методы для CRUD операций с сотрудниками,
|
||||
управления связями с отделами и синхронизацией с LDAP.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def load_users_index(
|
||||
dtos: Iterable[LdapPersonDTO],
|
||||
) -> Tuple[Dict[str, Employee], Dict[str, Employee]]:
|
||||
"""Строит индексы существующих пользователей по GUID и email.
|
||||
|
||||
Args:
|
||||
dtos: Итератор DTO пользователей из LDAP.
|
||||
|
||||
Returns:
|
||||
Кортеж (by_guid, by_email) - словари для поиска.
|
||||
"""
|
||||
guids: Set[str] = {
|
||||
d.guid.strip() for d in dtos if getattr(d, "guid", None)
|
||||
}
|
||||
emails_lower: Set[str] = {
|
||||
d.email.strip().lower() for d in dtos if getattr(d, "email", None)
|
||||
}
|
||||
|
||||
by_email: Dict[str, Employee] = {}
|
||||
if emails_lower:
|
||||
# Берём ровно те email'ы, что пришли, и кладём ключами lower()
|
||||
qs = Employee.objects.filter(
|
||||
email__in=list(emails_lower)
|
||||
).only("id", "email")
|
||||
for u in qs:
|
||||
if u.email:
|
||||
by_email[u.email.lower()] = u
|
||||
|
||||
# guid -> Employee через sync-state
|
||||
by_guid: Dict[str, Employee] = {}
|
||||
if guids:
|
||||
states = LdapSyncState.objects.filter(
|
||||
model="employee",
|
||||
ldap_guid__in=guids,
|
||||
).values("ldap_guid", "object_pk")
|
||||
emp_map = {
|
||||
str(e.pk): e
|
||||
for e in Employee.objects.filter(
|
||||
pk__in={str(s["object_pk"]) for s in states}
|
||||
)
|
||||
}
|
||||
for s in states:
|
||||
emp = emp_map.get(str(s["object_pk"]))
|
||||
if emp:
|
||||
by_guid[str(s["ldap_guid"])] = emp
|
||||
|
||||
return by_guid, by_email
|
||||
|
||||
@staticmethod
|
||||
def find_user_for_dto(
|
||||
dto: LdapPersonDTO,
|
||||
*,
|
||||
by_guid: Dict[str, Employee],
|
||||
by_email: Dict[str, Employee],
|
||||
) -> Optional[Employee]:
|
||||
"""Находит пользователя по GUID, иначе по e-mail (lower).
|
||||
|
||||
Args:
|
||||
dto: DTO пользователя из LDAP.
|
||||
by_guid: Индекс пользователей по GUID.
|
||||
by_email: Индекс пользователей по email.
|
||||
|
||||
Returns:
|
||||
Employee или None, если не найден.
|
||||
"""
|
||||
if dto.guid and dto.guid in by_guid:
|
||||
return by_guid[dto.guid]
|
||||
if dto.email:
|
||||
return by_email.get(dto.email.lower())
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def bind_user_department(user: Employee, dn: str) -> None:
|
||||
"""Привязывает пользователя к отделу на основе DN.
|
||||
|
||||
Извлекает название отдела из DN и создаёт/обновляет связь
|
||||
EmployeeDepartment, деактивируя старые связи.
|
||||
|
||||
Args:
|
||||
user: Пользователь.
|
||||
dn: Distinguished Name пользователя в LDAP.
|
||||
|
||||
Raises:
|
||||
django.db.DatabaseError: Ошибки записи в БД.
|
||||
"""
|
||||
dept_name = extract_department_from_dn(dn)
|
||||
if dept_name:
|
||||
dept_obj, _ = Department.objects.get_or_create(name=dept_name)
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee=user, is_active=True
|
||||
).exclude(department=dept_obj).update(is_active=False)
|
||||
link, made = EmployeeDepartment.objects.get_or_create(
|
||||
employee=user,
|
||||
department=dept_obj,
|
||||
defaults={"is_active": True},
|
||||
)
|
||||
if not made and not link.is_active:
|
||||
link.is_active = True
|
||||
link.save(update_fields=["is_active"])
|
||||
else:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee=user, is_active=True
|
||||
).update(is_active=False)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_absent_users(
|
||||
*,
|
||||
seen_guids: Set[str],
|
||||
seen_dns: Set[str],
|
||||
dry_run: bool,
|
||||
show_changes: bool = False,
|
||||
) -> int:
|
||||
"""Обрабатывает сотрудников, отсутствующих в LDAP (зеркальный режим).
|
||||
|
||||
Удаляет или деактивирует сотрудников, которых больше нет в LDAP.
|
||||
Сотрудники с утверждёнными/отклонёнными заявками деактивируются,
|
||||
остальные удаляются.
|
||||
|
||||
Args:
|
||||
seen_guids: Множество GUID, найденных в LDAP.
|
||||
seen_dns: Множество DN, найденных в LDAP.
|
||||
dry_run: Если True, только подсчёт без изменений.
|
||||
show_changes: Показывать детали изменений.
|
||||
|
||||
Returns:
|
||||
Количество удалённых пользователей.
|
||||
"""
|
||||
st_qs = LdapSyncState.objects.filter(model="employee")
|
||||
|
||||
# Находим состояния, которых больше нет в LDAP
|
||||
stale_states = st_qs
|
||||
if seen_guids:
|
||||
stale_states = (
|
||||
stale_states.exclude(ldap_guid__in=seen_guids)
|
||||
.exclude(ldap_guid__isnull=True)
|
||||
.exclude(ldap_guid="")
|
||||
)
|
||||
if seen_dns:
|
||||
stale_states = stale_states.exclude(ldap_dn__in=seen_dns).exclude(
|
||||
ldap_dn=""
|
||||
)
|
||||
|
||||
emp_ids = list(stale_states.values_list("object_pk", flat=True))
|
||||
stale_qs = Employee.objects.filter(pk__in=emp_ids, is_ldap_managed=True)
|
||||
|
||||
# Кто «заблокирован» из-за уже согласованных/отклонённых заявок
|
||||
blocked_ids_qs = (
|
||||
Request.objects.filter(
|
||||
approver__in=stale_qs.values("pk"),
|
||||
status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED],
|
||||
)
|
||||
.values_list("approver_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
blocked_qs = stale_qs.filter(pk__in=blocked_ids_qs)
|
||||
to_delete_qs = stale_qs.exclude(pk__in=blocked_ids_qs)
|
||||
|
||||
if show_changes:
|
||||
blocked_emails = list(blocked_qs.values_list("email", flat=True))
|
||||
to_delete_emails = list(to_delete_qs.values_list("email", flat=True))
|
||||
for e in to_delete_emails:
|
||||
verb = "будет удалён" if dry_run else "удалён"
|
||||
print(f"[CHG] - User: {e} ({verb})")
|
||||
for e in blocked_emails:
|
||||
verb = "будет деактивирован" if dry_run else "деактивирован"
|
||||
print(
|
||||
f"[CHG] ! User: {e} ({verb}; "
|
||||
f"есть утверждённые/отклонённые заявки)"
|
||||
)
|
||||
|
||||
blocked_count = blocked_qs.count()
|
||||
to_delete_count = to_delete_qs.count()
|
||||
|
||||
print(
|
||||
f"[LDAP] Отсутствуют в каталоге: всего={stale_qs.count()}, "
|
||||
f"к удалению={to_delete_count}, к деактивации={blocked_count} "
|
||||
f"(из-за заявлений APPROVED/REJECTED)"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return to_delete_count
|
||||
|
||||
# 1) Деактивируем «заблокированных к удалению»
|
||||
if blocked_count:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee__in=blocked_qs.values("pk"), is_active=True
|
||||
).update(is_active=False)
|
||||
Department.objects.filter(
|
||||
head__in=blocked_qs.values("pk")
|
||||
).update(head=None)
|
||||
blocked_qs.update(
|
||||
is_active=False,
|
||||
is_ldap_managed=False,
|
||||
)
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee",
|
||||
object_pk__in=blocked_qs.values_list("pk", flat=True),
|
||||
).delete()
|
||||
|
||||
# 2) Удаляем остальных
|
||||
if to_delete_count:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee__in=to_delete_qs.values("pk")
|
||||
).delete()
|
||||
Department.objects.filter(
|
||||
head__in=to_delete_qs.values("pk")
|
||||
).update(head=None)
|
||||
del_ids = list(to_delete_qs.values_list("pk", flat=True))
|
||||
to_delete_qs.delete()
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk__in=del_ids
|
||||
).delete()
|
||||
|
||||
return to_delete_count
|
||||
|
||||
|
||||
# Функции для обратной совместимости
|
||||
def _load_existing_users_index(
|
||||
dtos: Iterable[LdapPersonDTO],
|
||||
) -> Tuple[Dict[str, Employee], Dict[str, Employee]]:
|
||||
"""Обратная совместимость. Использует EmployeeRepository."""
|
||||
return EmployeeRepository.load_users_index(dtos)
|
||||
|
||||
|
||||
def _find_user_for_dto(
|
||||
dto: LdapPersonDTO,
|
||||
*,
|
||||
by_guid: Dict[str, Employee],
|
||||
by_email: Dict[str, Employee],
|
||||
) -> Optional[Employee]:
|
||||
"""Обратная совместимость. Использует EmployeeRepository."""
|
||||
return EmployeeRepository.find_user_for_dto(
|
||||
dto, by_guid=by_guid, by_email=by_email
|
||||
)
|
||||
|
||||
|
||||
def _bind_user_department(user: Employee, dn: str) -> None:
|
||||
"""Обратная совместимость. Использует EmployeeRepository."""
|
||||
return EmployeeRepository.bind_user_department(user, dn)
|
||||
|
||||
|
||||
def _cleanup_absent_users(
|
||||
*,
|
||||
seen_guids: Set[str],
|
||||
seen_dns: Set[str],
|
||||
dry_run: bool,
|
||||
show_changes: bool = False,
|
||||
) -> int:
|
||||
"""Обратная совместимость. Использует EmployeeRepository."""
|
||||
return EmployeeRepository.cleanup_absent_users(
|
||||
seen_guids=seen_guids,
|
||||
seen_dns=seen_dns,
|
||||
dry_run=dry_run,
|
||||
show_changes=show_changes,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmployeeRepository",
|
||||
# Обратная совместимость
|
||||
"_load_existing_users_index",
|
||||
"_find_user_for_dto",
|
||||
"_bind_user_department",
|
||||
"_cleanup_absent_users",
|
||||
]
|
||||
277
backend/employees/ldap/repositories/ldap_repository.py
Normal file
277
backend/employees/ldap/repositories/ldap_repository.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""LDAP репозиторий - низкоуровневые операции с LDAP.
|
||||
|
||||
Предоставляет методы для работы с LDAP: чтение, поиск, модификация атрибутов.
|
||||
Это слой доступа к данным, который изолирует бизнес-логику от деталей LDAP API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set
|
||||
|
||||
from django.conf import settings
|
||||
from ldap3 import BASE, MODIFY_REPLACE, SUBTREE, Connection
|
||||
|
||||
from ..utils.text_utils import esc_filter
|
||||
|
||||
|
||||
class LdapRepository:
|
||||
"""Репозиторий для работы с LDAP.
|
||||
|
||||
Инкапсулирует низкоуровневые операции с LDAP, предоставляя
|
||||
чистый интерфейс для бизнес-логики.
|
||||
"""
|
||||
|
||||
def __init__(self, conn: Connection):
|
||||
"""
|
||||
Args:
|
||||
conn: Активное LDAP-соединение.
|
||||
"""
|
||||
self.conn = conn
|
||||
|
||||
def read_attrs(
|
||||
self, dn: str, attrs: List[str]
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Читает значения указанных LDAP-атрибутов у записи по DN.
|
||||
|
||||
Args:
|
||||
dn: Distinguished Name записи.
|
||||
attrs: Список атрибутов для чтения.
|
||||
|
||||
Returns:
|
||||
Словарь {атрибут: значение}, где значение может быть None.
|
||||
|
||||
Raises:
|
||||
TypeError: Если параметры неверного типа.
|
||||
"""
|
||||
if not isinstance(dn, str) or not dn.strip():
|
||||
raise TypeError("dn должен быть непустой строкой")
|
||||
if not isinstance(attrs, list) or not all(
|
||||
isinstance(a, str) for a in attrs
|
||||
):
|
||||
raise TypeError("attrs должен быть списком строк")
|
||||
|
||||
out: Dict[str, Optional[str]] = {a: None for a in attrs}
|
||||
try:
|
||||
ok = self.conn.search(
|
||||
search_base=dn,
|
||||
search_filter="(objectClass=*)",
|
||||
search_scope=BASE,
|
||||
attributes=attrs,
|
||||
)
|
||||
if not ok or not self.conn.entries:
|
||||
return out
|
||||
entry = self.conn.entries[0]
|
||||
for a in attrs:
|
||||
try:
|
||||
raw = getattr(getattr(entry, a, None), "value", None)
|
||||
if isinstance(raw, (list, tuple)):
|
||||
raw = raw[0] if raw else None
|
||||
out[a] = str(raw).strip() if raw is not None else None
|
||||
except Exception:
|
||||
out[a] = None
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
def is_taken(
|
||||
self,
|
||||
*,
|
||||
attributes: Mapping[str, Optional[str]],
|
||||
object_classes: Sequence[str] = ("user",),
|
||||
object_category: str = "person",
|
||||
base_dn: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Проверяет занятость атрибутов в домене (LDAP search OR).
|
||||
|
||||
Args:
|
||||
attributes: Пары {атрибут: значение}. Пустые/None игнорируются.
|
||||
object_classes: Список objectClass для фильтрации.
|
||||
object_category: Значение objectCategory.
|
||||
base_dn: База поиска. Если не указана, берётся из settings.
|
||||
|
||||
Returns:
|
||||
True, если найден хотя бы один объект с любым из указанных
|
||||
значений.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не задан base DN.
|
||||
TypeError: Если параметры неверного типа.
|
||||
"""
|
||||
base = base_dn or getattr(settings, "LDAP_BASE_DN", None)
|
||||
if not base:
|
||||
raise RuntimeError("LDAP_BASE_DN must be set")
|
||||
|
||||
# Валидация входных данных
|
||||
if not isinstance(attributes, Mapping):
|
||||
raise TypeError("attributes must be a mapping")
|
||||
if not all(isinstance(k, str) and k.strip() for k in attributes.keys()):
|
||||
raise TypeError("All attribute names must be non-empty strings")
|
||||
if not all(
|
||||
(v is None) or isinstance(v, str) for v in attributes.values()
|
||||
):
|
||||
raise TypeError("All attribute values must be strings or None")
|
||||
|
||||
# (attr1=val1) OR (attr2=val2) ...
|
||||
attr_filters = [
|
||||
f"({attr}={esc_filter(value)})"
|
||||
for attr, value in attributes.items()
|
||||
if value
|
||||
]
|
||||
if not attr_filters:
|
||||
return False # нечего проверять
|
||||
|
||||
# AND с категорией и классами
|
||||
class_filter = "".join(
|
||||
f"(objectClass={esc_filter(cls)})" for cls in object_classes
|
||||
)
|
||||
ldap_filter = (
|
||||
f"(&(|{''.join(attr_filters)})"
|
||||
f"(objectCategory={esc_filter(object_category)}){class_filter})"
|
||||
)
|
||||
|
||||
self.conn.search(
|
||||
search_base=base,
|
||||
search_filter=ldap_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=["cn"],
|
||||
size_limit=1,
|
||||
)
|
||||
return bool(self.conn.entries)
|
||||
|
||||
def modify_attrs(
|
||||
self, dn: str, attrs: dict[str, object], *, do_write: bool = True
|
||||
) -> None:
|
||||
"""MODIFY_REPLACE атрибутов объекта; None-значения пропускаются.
|
||||
|
||||
Args:
|
||||
dn: Distinguished Name объекта.
|
||||
attrs: Словарь {атрибут: значение}.
|
||||
do_write: Выполнить изменения (False = dry-run).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если операция завершилась ошибкой.
|
||||
"""
|
||||
changes = {
|
||||
k: [
|
||||
(
|
||||
MODIFY_REPLACE,
|
||||
[v] if not isinstance(v, (list, tuple)) else list(v),
|
||||
)
|
||||
]
|
||||
for k, v in attrs.items()
|
||||
if v is not None
|
||||
}
|
||||
if not changes or not do_write:
|
||||
return
|
||||
if not self.conn.modify(dn, changes=changes):
|
||||
raise RuntimeError(
|
||||
f"LDAP modify failed for {dn}: {self.conn.result}"
|
||||
)
|
||||
|
||||
def ensure_container_exists(self, base_dn: str) -> None:
|
||||
"""Проверяет, что контейнер (OU) существует.
|
||||
|
||||
Args:
|
||||
base_dn: Distinguished Name контейнера.
|
||||
|
||||
Raises:
|
||||
TypeError: Если base_dn неверного типа.
|
||||
RuntimeError: Если контейнер не найден.
|
||||
"""
|
||||
if not isinstance(base_dn, str) or not base_dn.strip():
|
||||
raise TypeError("base_dn должен быть непустой строкой")
|
||||
ok = self.conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter="(objectClass=organizationalUnit)",
|
||||
search_scope=BASE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if not ok or not self.conn.entries:
|
||||
raise RuntimeError(f"Контейнер для создания не найден: {base_dn}")
|
||||
|
||||
def modify_or_ignore(
|
||||
self,
|
||||
dn: str,
|
||||
changes: Dict[str, Any],
|
||||
ignore_descriptions: Set[str] | None = None,
|
||||
) -> None:
|
||||
"""Общий modify с белым списком «нефатальных» описаний.
|
||||
|
||||
Args:
|
||||
dn: DN объекта.
|
||||
changes: Карта изменений для conn.modify.
|
||||
ignore_descriptions: Список описаний, которые не считаем ошибкой.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если modify вернул ошибку вне разрешённых.
|
||||
"""
|
||||
ok = self.conn.modify(dn, changes)
|
||||
if ok:
|
||||
return
|
||||
desc = (self.conn.result or {}).get("description", "")
|
||||
if ignore_descriptions and desc in ignore_descriptions:
|
||||
return
|
||||
raise RuntimeError(f"LDAP modify failed: {self.conn.result}")
|
||||
|
||||
|
||||
# Функции для обратной совместимости (делегируют к репозиторию)
|
||||
def read_attrs(
|
||||
conn: Connection, dn: str, attrs: List[str]
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Обратная совместимость. Использует LdapRepository."""
|
||||
repo = LdapRepository(conn)
|
||||
return repo.read_attrs(dn, attrs)
|
||||
|
||||
|
||||
def is_taken(
|
||||
conn: Connection,
|
||||
*,
|
||||
attributes: Mapping[str, Optional[str]],
|
||||
object_classes: Sequence[str] = ("user",),
|
||||
object_category: str = "person",
|
||||
base_dn: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Обратная совместимость. Использует LdapRepository."""
|
||||
repo = LdapRepository(conn)
|
||||
return repo.is_taken(
|
||||
attributes=attributes,
|
||||
object_classes=object_classes,
|
||||
object_category=object_category,
|
||||
base_dn=base_dn,
|
||||
)
|
||||
|
||||
|
||||
def modify_user_attrs(
|
||||
conn: Connection, user_dn: str, attrs: dict[str, object], *, do_write: bool
|
||||
) -> None:
|
||||
"""Обратная совместимость. Использует LdapRepository."""
|
||||
repo = LdapRepository(conn)
|
||||
return repo.modify_attrs(user_dn, attrs, do_write=do_write)
|
||||
|
||||
|
||||
def ensure_container_exists(conn: Connection, base_dn: str) -> None:
|
||||
"""Обратная совместимость. Использует LdapRepository."""
|
||||
repo = LdapRepository(conn)
|
||||
return repo.ensure_container_exists(base_dn)
|
||||
|
||||
|
||||
def ldap_modify_or_ignore(
|
||||
conn: Connection,
|
||||
dn: str,
|
||||
changes: Dict[str, Any],
|
||||
ignore_descriptions: Set[str] | None = None,
|
||||
) -> None:
|
||||
"""Обратная совместимость. Использует LdapRepository."""
|
||||
repo = LdapRepository(conn)
|
||||
return repo.modify_or_ignore(dn, changes, ignore_descriptions)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LdapRepository",
|
||||
# Функции обратной совместимости
|
||||
"read_attrs",
|
||||
"is_taken",
|
||||
"modify_user_attrs",
|
||||
"ensure_container_exists",
|
||||
"ldap_modify_or_ignore",
|
||||
]
|
||||
160
backend/employees/ldap/repositories/sync_state_repository.py
Normal file
160
backend/employees/ldap/repositories/sync_state_repository.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Репозиторий для работы с состоянием синхронизации LDAP.
|
||||
|
||||
Управляет записями LdapSyncState, которые отслеживают соответствие между
|
||||
объектами Django и LDAP (DN, GUID, временные метки).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import Employee, LdapSyncState
|
||||
|
||||
|
||||
class SyncStateRepository:
|
||||
"""Репозиторий для работы с LdapSyncState.
|
||||
|
||||
Предоставляет методы для создания, обновления и поиска состояний
|
||||
синхронизации объектов между Django и LDAP.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_or_create(
|
||||
model: str, object_pk: str
|
||||
) -> tuple[LdapSyncState, bool]:
|
||||
"""Получить или создать запись состояния синхронизации.
|
||||
|
||||
Args:
|
||||
model: Тип модели ('employee', 'department').
|
||||
object_pk: PK объекта в Django.
|
||||
|
||||
Returns:
|
||||
Кортеж (state, created).
|
||||
"""
|
||||
return LdapSyncState.objects.get_or_create(
|
||||
model=model, object_pk=str(object_pk)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_state(model: str, object_pk: str) -> Optional[LdapSyncState]:
|
||||
"""Получить состояние синхронизации или None.
|
||||
|
||||
Args:
|
||||
model: Тип модели ('employee', 'department').
|
||||
object_pk: PK объекта в Django.
|
||||
|
||||
Returns:
|
||||
LdapSyncState или None, если не найдено.
|
||||
"""
|
||||
try:
|
||||
return LdapSyncState.objects.get(
|
||||
model=model, object_pk=str(object_pk)
|
||||
)
|
||||
except LdapSyncState.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def touch(
|
||||
emp: Employee,
|
||||
*,
|
||||
dn: str,
|
||||
guid: Optional[str] = None,
|
||||
last_ldap_modify_ts: Optional[datetime] = None,
|
||||
last_django_modify_ts: Optional[datetime] = None,
|
||||
sync_dir: str = "ldap",
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Обновить/создать запись состояния синхронизации для сотрудника.
|
||||
|
||||
Args:
|
||||
emp: Объект сотрудника.
|
||||
dn: Distinguished Name в LDAP.
|
||||
guid: GUID объекта в LDAP.
|
||||
last_ldap_modify_ts: Время последнего изменения в LDAP.
|
||||
last_django_modify_ts: Время последнего изменения в Django.
|
||||
sync_dir: Направление синхронизации ('ldap' или 'django').
|
||||
dry_run: Если True, не выполнять изменения.
|
||||
"""
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
)
|
||||
state.touch(
|
||||
ldap_dn=dn,
|
||||
ldap_guid=guid or None,
|
||||
last_ldap_modify_ts=last_ldap_modify_ts,
|
||||
last_django_modify_ts=last_django_modify_ts or timezone.now(),
|
||||
sync_dir=sync_dir,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_employees_with_dn() -> QuerySet[Employee]:
|
||||
"""Получить всех сотрудников с заполненным DN в sync-state.
|
||||
|
||||
Returns:
|
||||
QuerySet сотрудников.
|
||||
"""
|
||||
emp_ids = (
|
||||
LdapSyncState.objects.filter(model="employee")
|
||||
.exclude(ldap_dn="")
|
||||
.values_list("object_pk", flat=True)
|
||||
)
|
||||
return Employee.objects.filter(pk__in=list(emp_ids)).select_related()
|
||||
|
||||
@staticmethod
|
||||
def delete_for_employee(emp_pk: int | str) -> None:
|
||||
"""Удалить состояние синхронизации для сотрудника.
|
||||
|
||||
Args:
|
||||
emp_pk: PK сотрудника.
|
||||
"""
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk=str(emp_pk)
|
||||
).delete()
|
||||
|
||||
@staticmethod
|
||||
def bulk_delete_for_employees(emp_pks: list[int | str]) -> None:
|
||||
"""Удалить состояния синхронизации для нескольких сотрудников.
|
||||
|
||||
Args:
|
||||
emp_pks: Список PK сотрудников.
|
||||
"""
|
||||
str_pks = [str(pk) for pk in emp_pks]
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk__in=str_pks
|
||||
).delete()
|
||||
|
||||
|
||||
# Функция для обратной совместимости
|
||||
def _touch_sync_state(
|
||||
user: Employee,
|
||||
*,
|
||||
dn: str,
|
||||
guid: Optional[str],
|
||||
last_ldap_modify_ts: Optional[datetime] = None,
|
||||
last_django_modify_ts: Optional[datetime] = None,
|
||||
sync_dir: str = "ldap",
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Обратная совместимость. Использует SyncStateRepository."""
|
||||
SyncStateRepository.touch(
|
||||
user,
|
||||
dn=dn,
|
||||
guid=guid,
|
||||
last_ldap_modify_ts=last_ldap_modify_ts,
|
||||
last_django_modify_ts=last_django_modify_ts,
|
||||
sync_dir=sync_dir,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SyncStateRepository",
|
||||
"_touch_sync_state", # Обратная совместимость
|
||||
]
|
||||
16
backend/employees/ldap/services/__init__.py
Normal file
16
backend/employees/ldap/services/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Слой сервисов модуля LDAP.
|
||||
|
||||
Содержит бизнес-логику синхронизации и управления объектами LDAP.
|
||||
"""
|
||||
|
||||
from .department_service import DepartmentService
|
||||
from .user_service import UserService
|
||||
from .group_service import GroupService
|
||||
from .position_service import PositionService
|
||||
|
||||
__all__ = [
|
||||
"DepartmentService",
|
||||
"UserService",
|
||||
"GroupService",
|
||||
"PositionService",
|
||||
]
|
||||
1052
backend/employees/ldap/services/department_service.py
Normal file
1052
backend/employees/ldap/services/department_service.py
Normal file
File diff suppressed because it is too large
Load Diff
400
backend/employees/ldap/services/group_service.py
Normal file
400
backend/employees/ldap/services/group_service.py
Normal file
@ -0,0 +1,400 @@
|
||||
"""Сервис для управления группами Active Directory.
|
||||
|
||||
Этот модуль содержит бизнес-логику для операций с группами AD,
|
||||
включая создание, удаление, переименование и управление членством.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from employees.models import LdapSyncState
|
||||
from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, SUBTREE, Connection
|
||||
|
||||
from ..infrastructure.connections import _ldap
|
||||
from ..repositories.ldap_repository import ldap_modify_or_ignore
|
||||
from ..utils.text_utils import esc_filter, esc_rdn
|
||||
from ..utils.ldap_utils import get_guid_str, group_type
|
||||
|
||||
|
||||
class GroupService:
|
||||
"""Сервис для управления группами Active Directory."""
|
||||
|
||||
def __init__(self, directory_service=None):
|
||||
"""Инициализирует GroupService.
|
||||
|
||||
Args:
|
||||
directory_service: Опциональная ссылка на DirectoryService для _touch_state
|
||||
"""
|
||||
self._directory_service = directory_service
|
||||
|
||||
def _touch_state(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
object_pk: int | str,
|
||||
ldap_dn: Optional[str] = None,
|
||||
ldap_guid: Optional[str] = None,
|
||||
last_django_modify_ts: Optional[Any] = None,
|
||||
sync_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Обновляет запись LdapSyncState для модели.
|
||||
|
||||
Делегирует в DirectoryService, если доступен, иначе работает напрямую.
|
||||
"""
|
||||
if self._directory_service:
|
||||
self._directory_service._touch_state(
|
||||
model=model,
|
||||
object_pk=object_pk,
|
||||
ldap_dn=ldap_dn,
|
||||
ldap_guid=ldap_guid,
|
||||
last_django_modify_ts=last_django_modify_ts,
|
||||
sync_dir=sync_dir,
|
||||
)
|
||||
else:
|
||||
# Fallback - прямая работа с LdapSyncState
|
||||
state, created = LdapSyncState.objects.get_or_create(
|
||||
model=model, object_pk=str(object_pk)
|
||||
)
|
||||
if ldap_dn is not None:
|
||||
state.ldap_dn = ldap_dn
|
||||
if ldap_guid is not None:
|
||||
state.ldap_guid = ldap_guid
|
||||
if last_django_modify_ts is not None:
|
||||
state.last_django_modify_ts = last_django_modify_ts
|
||||
if sync_dir is not None:
|
||||
state.sync_dir = sync_dir
|
||||
state.save()
|
||||
|
||||
# ======================== Core CRUD Operations ======================== #
|
||||
|
||||
def create(
|
||||
self,
|
||||
conn: Connection,
|
||||
cn: str,
|
||||
parent_dn: Optional[str] = None,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
scope: str = "global",
|
||||
security_enabled: bool = True,
|
||||
) -> str:
|
||||
"""Создаёт группу в LDAP под указанным контейнером.
|
||||
|
||||
Args:
|
||||
conn: Подключение LDAP.
|
||||
cn: Имя группы (CN).
|
||||
parent_dn: Контейнер для группы; если None — settings.LDAP_GROUPS_BASE.
|
||||
description: Описание.
|
||||
scope: 'global' | 'domain_local' | 'universal'.
|
||||
security_enabled: Флаг безопасности.
|
||||
|
||||
Returns:
|
||||
DN созданной (или существующей) группы.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если базовый контейнер не задан или операция add завершилась ошибкой.
|
||||
"""
|
||||
base = parent_dn or getattr(settings, "LDAP_GROUPS_BASE", None)
|
||||
if not base:
|
||||
raise RuntimeError(
|
||||
"LDAP_GROUPS_BASE is not configured and parent_dn is not provided"
|
||||
)
|
||||
|
||||
dn = f"CN={esc_rdn(cn)},{base}"
|
||||
attrs: Dict[str, Any] = {
|
||||
"cn": cn,
|
||||
"sAMAccountName": cn,
|
||||
"groupType": group_type(scope, security_enabled),
|
||||
}
|
||||
if description:
|
||||
attrs["description"] = description
|
||||
|
||||
ok = conn.add(dn, ["top", "group"], attrs)
|
||||
if not ok and (conn.result or {}).get("description") != "entryAlreadyExists":
|
||||
raise RuntimeError(f"LDAP add group failed: {conn.result}")
|
||||
return dn
|
||||
|
||||
def delete(self, conn: Connection, group_dn: str) -> None:
|
||||
"""Удаляет группу по DN (игнорирует noSuchObject)."""
|
||||
ok = conn.delete(group_dn)
|
||||
if not ok and (conn.result or {}).get("description") not in {"noSuchObject"}:
|
||||
raise RuntimeError(f"LDAP delete group failed: {conn.result}")
|
||||
|
||||
def rename(self, conn: Connection, group_dn: str, new_cn: str) -> str:
|
||||
"""Переименовывает группу (modify_dn)."""
|
||||
new_rdn = f"CN={esc_rdn(new_cn)}"
|
||||
ok = conn.modify_dn(group_dn, new_rdn)
|
||||
if not ok:
|
||||
raise RuntimeError(f"LDAP rename group failed: {conn.result}")
|
||||
base = ",".join(group_dn.split(",")[1:])
|
||||
return f"{new_rdn},{base}"
|
||||
|
||||
def set_description(
|
||||
self, conn: Connection, group_dn: str, description: Optional[str]
|
||||
) -> None:
|
||||
"""Ставит/очищает description."""
|
||||
changes = (
|
||||
{"description": [(MODIFY_REPLACE, [description])]}
|
||||
if description
|
||||
else {"description": [(MODIFY_DELETE, [])]}
|
||||
)
|
||||
ldap_modify_or_ignore(conn, group_dn, changes, {"noSuchAttribute"})
|
||||
|
||||
# ======================== Member Management ======================== #
|
||||
|
||||
def _modify_members(
|
||||
self, conn: Connection, group_dn: str, op: int, members: List[str]
|
||||
) -> None:
|
||||
"""Применяет операцию к членству группы (DRY-хелпер).
|
||||
|
||||
Args:
|
||||
conn: Подключение LDAP.
|
||||
group_dn: DN группы.
|
||||
op: MODIFY_ADD | MODIFY_DELETE | MODIFY_REPLACE.
|
||||
members: Список DN участников.
|
||||
|
||||
Raises:
|
||||
RuntimeError: При ошибке modify вне допустимых кейсов.
|
||||
"""
|
||||
if not members and op in (MODIFY_ADD, MODIFY_DELETE):
|
||||
return
|
||||
ignore = (
|
||||
{"typeOrValueExists"}
|
||||
if op == MODIFY_ADD
|
||||
else (
|
||||
{"noSuchAttribute", "noSuchObject"}
|
||||
if op == MODIFY_DELETE
|
||||
else {"noSuchAttribute"}
|
||||
)
|
||||
)
|
||||
ldap_modify_or_ignore(conn, group_dn, {"member": [(op, members or [])]}, ignore)
|
||||
|
||||
def add_members(
|
||||
self, conn: Connection, group_dn: str, member_dns: List[str]
|
||||
) -> None:
|
||||
"""Добавляет участников (member ADD)."""
|
||||
self._modify_members(conn, group_dn, MODIFY_ADD, member_dns)
|
||||
|
||||
def remove_members(
|
||||
self, conn: Connection, group_dn: str, member_dns: List[str]
|
||||
) -> None:
|
||||
"""Удаляет участников (member DELETE)."""
|
||||
self._modify_members(conn, group_dn, MODIFY_DELETE, member_dns)
|
||||
|
||||
def replace_members(
|
||||
self, conn: Connection, group_dn: str, exact_member_dns: List[str]
|
||||
) -> None:
|
||||
"""Полная замена состава группы."""
|
||||
self._modify_members(conn, group_dn, MODIFY_REPLACE, exact_member_dns)
|
||||
|
||||
def list_members(self, conn: Connection, group_dn: str) -> List[str]:
|
||||
"""Возвращает DN всех участников группы."""
|
||||
ok = conn.search(
|
||||
search_base=group_dn,
|
||||
search_filter="(objectClass=group)",
|
||||
search_scope=BASE,
|
||||
attributes=["member"],
|
||||
)
|
||||
if not ok or not conn.entries:
|
||||
return []
|
||||
entry = conn.entries[0]
|
||||
vals = getattr(getattr(entry, "member", None), "values", None)
|
||||
return list(vals) if vals else []
|
||||
|
||||
# ======================== Finding and Search ======================== #
|
||||
|
||||
def find_dn(
|
||||
self, conn: Connection, cn: str, bases: Optional[List[str]] = None
|
||||
) -> Optional[str]:
|
||||
"""Ищет DN группы по CN в заданных базах."""
|
||||
bases = list(bases or [])
|
||||
if not bases:
|
||||
gb = getattr(settings, "LDAP_GROUPS_BASE", None)
|
||||
if gb:
|
||||
bases.append(gb)
|
||||
for base in bases:
|
||||
ok = conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(cn={esc_filter(cn)})",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if ok and conn.entries:
|
||||
return str(conn.entries[0].entry_dn)
|
||||
return None
|
||||
|
||||
def groups_with_member(self, conn: Connection, member_dn: str) -> Set[str]:
|
||||
"""Находит все группы, в которых состоит указанный DN.
|
||||
|
||||
Args:
|
||||
conn: Подключение LDAP.
|
||||
member_dn: DN участника.
|
||||
|
||||
Returns:
|
||||
Множество DN групп.
|
||||
"""
|
||||
base = getattr(settings, "LDAP_GROUPS_BASE", None) or getattr(
|
||||
settings, "LDAP_BASE_DN", None
|
||||
)
|
||||
if not base:
|
||||
return set()
|
||||
|
||||
ok = conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(member={esc_filter(member_dn)}))",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if not ok:
|
||||
return set()
|
||||
return {str(e.entry_dn) for e in conn.entries}
|
||||
|
||||
# ======================== Catalog Synchronization ======================== #
|
||||
|
||||
def sync_catalog(
|
||||
self, *, throttle_seconds: int = 60, delete_absent: bool = False
|
||||
) -> int:
|
||||
"""
|
||||
Тянет группы из AD под LDAP_GROUPS_BASE и гарантирует их наличие в Django Group.
|
||||
Возвращает количество созданных групп. Обновляет LdapSyncState(model='group').
|
||||
Троттлит запросы к AD не чаще, чем раз в throttle_seconds.
|
||||
"""
|
||||
base = getattr(settings, "LDAP_GROUPS_BASE", None) or getattr(
|
||||
settings, "LDAP_BASE_DN", None
|
||||
)
|
||||
if not base:
|
||||
return 0
|
||||
|
||||
# простая защита от шторминга
|
||||
now = int(time.time())
|
||||
last = cache.get("ad_groups_sync_last_ts") or 0
|
||||
if throttle_seconds and now - last < throttle_seconds:
|
||||
return 0
|
||||
if not cache.add("ad_groups_sync_lock", "1", timeout=throttle_seconds or 30):
|
||||
return 0
|
||||
|
||||
created = 0
|
||||
try:
|
||||
with _ldap() as conn:
|
||||
ok = conn.search(
|
||||
search_base=base,
|
||||
search_filter="(objectClass=group)",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["cn", "objectGUID"],
|
||||
)
|
||||
if not ok:
|
||||
return 0
|
||||
|
||||
# AD → {name_lower: (CN, DN, GUID)}
|
||||
ad_index = {}
|
||||
for e in conn.entries:
|
||||
cn = str(getattr(e, "cn", "")) or ""
|
||||
if not cn:
|
||||
continue
|
||||
dn = str(e.entry_dn)
|
||||
guid = get_guid_str({"objectGUID": getattr(e, "objectGUID", None)})
|
||||
ad_index[cn.lower()] = (cn, dn, guid)
|
||||
|
||||
# существующие группы в Django (без дублей по регистру)
|
||||
existing = {g.name.lower(): g for g in Group.objects.all()}
|
||||
|
||||
for key, (cn, dn, guid) in ad_index.items():
|
||||
g = existing.get(key)
|
||||
if not g:
|
||||
g = Group.objects.create(name=cn)
|
||||
created += 1
|
||||
existing[key] = g
|
||||
# фиксируем DN/GUID в sync-state
|
||||
self._touch_state(
|
||||
model="group",
|
||||
object_pk=g.pk,
|
||||
ldap_dn=dn,
|
||||
ldap_guid=guid,
|
||||
sync_dir="ldap",
|
||||
last_django_modify_ts=timezone.now(),
|
||||
)
|
||||
|
||||
if delete_absent:
|
||||
# по умолчанию НЕ удаляем отсутствующие в AD
|
||||
for key, g in list(existing.items()):
|
||||
if key not in ad_index:
|
||||
# например, можно только пометить/залогировать
|
||||
pass
|
||||
|
||||
cache.set("ad_groups_sync_last_ts", now, timeout=24 * 3600)
|
||||
return created
|
||||
finally:
|
||||
cache.delete("ad_groups_sync_lock")
|
||||
|
||||
# ======================== Public API with Connection Management ======================== #
|
||||
|
||||
def find_dn_wrapped(
|
||||
self, cn: str, bases: Optional[list[str]] = None
|
||||
) -> Optional[str]:
|
||||
"""Ищет DN группы (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
return self.find_dn(conn, cn, bases=bases)
|
||||
|
||||
def create_wrapped(
|
||||
self,
|
||||
*,
|
||||
cn: str,
|
||||
parent_dn: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
scope: str = "global",
|
||||
security_enabled: bool = True,
|
||||
) -> str:
|
||||
"""Создает группу (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
return self.create(
|
||||
conn,
|
||||
cn,
|
||||
parent_dn,
|
||||
description=description,
|
||||
scope=scope,
|
||||
security_enabled=security_enabled,
|
||||
)
|
||||
|
||||
def delete_wrapped(self, group_dn: str) -> None:
|
||||
"""Удаляет группу (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
self.delete(conn, group_dn)
|
||||
|
||||
def rename_wrapped(self, group_dn: str, new_cn: str) -> str:
|
||||
"""Переименовывает группу (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
return self.rename(conn, group_dn, new_cn)
|
||||
|
||||
def set_description_wrapped(
|
||||
self, group_dn: str, description: Optional[str]
|
||||
) -> None:
|
||||
"""Устанавливает описание группы (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
self.set_description(conn, group_dn, description)
|
||||
|
||||
def list_members_wrapped(self, group_dn: str) -> list[str]:
|
||||
"""Список участников группы (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
return self.list_members(conn, group_dn)
|
||||
|
||||
def add_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None:
|
||||
"""Добавляет участников (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
self.add_members(conn, group_dn, member_dns)
|
||||
|
||||
def remove_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None:
|
||||
"""Удаляет участников (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
self.remove_members(conn, group_dn, member_dns)
|
||||
|
||||
def replace_members_wrapped(
|
||||
self, group_dn: str, exact_member_dns: list[str]
|
||||
) -> None:
|
||||
"""Заменяет участников (открывает соединение автоматически)."""
|
||||
with _ldap() as conn:
|
||||
self.replace_members(conn, group_dn, exact_member_dns)
|
||||
389
backend/employees/ldap/services/position_service.py
Normal file
389
backend/employees/ldap/services/position_service.py
Normal file
@ -0,0 +1,389 @@
|
||||
"""
|
||||
Сервис для работы с должностями (Position) в Active Directory.
|
||||
|
||||
Обрабатывает создание, обновление, удаление POS-групп (агрегаторных групп должностей),
|
||||
управление участниками (сотрудники с должностью), вложение POS-групп в целевые группы.
|
||||
"""
|
||||
|
||||
from typing import Optional, Set
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from ldap3 import BASE, SUBTREE, Connection
|
||||
|
||||
from employees.models import Employee, Position, LdapSyncState
|
||||
|
||||
from ..errors import DirectoryServiceError
|
||||
from ..repositories.ldap_repository import ensure_container_exists
|
||||
from ..utils.text_utils import esc_filter, esc_rdn
|
||||
from ..utils.ldap_utils import group_type
|
||||
|
||||
|
||||
class PositionService:
|
||||
"""
|
||||
Сервис для работы с должностями (Position) в Active Directory.
|
||||
|
||||
Отвечает за:
|
||||
- Создание и управление POS-группами (CN=POS_<name>, OU=Positions)
|
||||
- Назначение/снятие должностей для сотрудников
|
||||
- Вложение POS-групп в целевые группы (из Position.groups)
|
||||
- Синхронизацию состава участников POS-группы
|
||||
|
||||
Зависимости (через Dependency Injection):
|
||||
- GroupService: для операций с группами (add_members, remove_members, replace_members)
|
||||
- UserService: для получения DN сотрудника
|
||||
"""
|
||||
|
||||
def __init__(self, group_service=None, user_service=None):
|
||||
"""
|
||||
Инициализация PositionService.
|
||||
|
||||
Args:
|
||||
group_service: Сервис для работы с группами (для управления членством)
|
||||
user_service: Сервис для работы с пользователями (для получения DN)
|
||||
"""
|
||||
self._group_service = group_service
|
||||
self._user_service = user_service
|
||||
|
||||
# ======================== PUBLIC API ======================== #
|
||||
|
||||
def reconcile_position(self, pos: Position) -> str:
|
||||
"""
|
||||
Публичный метод — открыть соединение и привести POS к консистентности.
|
||||
|
||||
Приводит должность к консистентному состоянию:
|
||||
1) Создаёт/обновляет POS-группу (CN=POS_<name>)
|
||||
2) Вкладывает POS-группу в целевые группы (из pos.groups)
|
||||
3) Синхронизирует участников POS-группы (сотрудники с этой должностью)
|
||||
|
||||
Args:
|
||||
pos: Должность для синхронизации
|
||||
|
||||
Returns:
|
||||
DN POS-группы
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||||
ValueError: Если имя должности пустое
|
||||
"""
|
||||
from ..infrastructure.connections import _ldap
|
||||
|
||||
with _ldap() as conn:
|
||||
return self._reconcile_position(conn, pos)
|
||||
|
||||
def assign_position(self, employee: Employee, position: Position) -> None:
|
||||
"""
|
||||
Добавляет сотрудника в POS-группу должности и убеждается, что вложения POS в целевые группы актуальны.
|
||||
|
||||
Args:
|
||||
employee: Сотрудник
|
||||
position: Должность
|
||||
|
||||
Raises:
|
||||
DirectoryServiceError: Если DN сотрудника не найден
|
||||
RuntimeError: Если операция LDAP не удалась
|
||||
"""
|
||||
from ..infrastructure.connections import _ldap
|
||||
|
||||
emp_dn = self._user_service._get_employee_dn(employee)
|
||||
with _ldap() as conn:
|
||||
pos_dn = self._ensure_position_group(conn, position)
|
||||
self._group_service.add_members(conn, pos_dn, [emp_dn])
|
||||
# вложения можно обновлять best-effort, не обязательно каждый раз
|
||||
try:
|
||||
self._reconcile_position_nesting(conn, position)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def unassign_position(self, employee: Employee, position: Position) -> None:
|
||||
"""
|
||||
Удаляет сотрудника из POS-группы должности.
|
||||
|
||||
Args:
|
||||
employee: Сотрудник
|
||||
position: Должность
|
||||
"""
|
||||
from ..infrastructure.connections import _ldap
|
||||
|
||||
try:
|
||||
emp_dn = self._user_service._get_employee_dn(employee)
|
||||
except DirectoryServiceError:
|
||||
emp_dn = None
|
||||
if not emp_dn:
|
||||
return
|
||||
with _ldap() as conn:
|
||||
pos_dn = self._ensure_position_group(conn, position)
|
||||
self._group_service.remove_members(conn, pos_dn, [emp_dn])
|
||||
|
||||
def delete_position_group(self, position: Position) -> None:
|
||||
"""
|
||||
Best-effort: снять POS-группу из всех правовых групп и удалить её.
|
||||
|
||||
Args:
|
||||
position: Должность с POS-группой для удаления
|
||||
"""
|
||||
from ..infrastructure.connections import _ldap
|
||||
|
||||
with _ldap() as conn:
|
||||
dn = (position.ldap_group_dn or "").strip()
|
||||
if not dn:
|
||||
return
|
||||
# снять вложения
|
||||
bases = [
|
||||
b
|
||||
for b in [
|
||||
getattr(settings, "LDAP_GROUPS_BASE", None),
|
||||
getattr(settings, "LDAP_BASE_DN", None),
|
||||
]
|
||||
if b
|
||||
]
|
||||
for base in bases:
|
||||
ok = conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(member={esc_filter(dn)}))",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if ok and conn.entries:
|
||||
for e in conn.entries:
|
||||
self._group_service.remove_members(conn, str(e.entry_dn), [dn])
|
||||
# удалить саму POS-группу
|
||||
try:
|
||||
self._group_service.delete(conn, dn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ======================== PRIVATE HELPERS ======================== #
|
||||
|
||||
def _positions_base(self) -> str:
|
||||
"""
|
||||
Получить базовый DN для контейнера должностей (OU=Positions).
|
||||
|
||||
Returns:
|
||||
DN контейнера должностей из настроек LDAP_POSITIONS_BASE
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||||
"""
|
||||
base = getattr(settings, "LDAP_POSITIONS_BASE", None)
|
||||
if not base:
|
||||
raise RuntimeError("LDAP_POSITIONS_BASE is not configured")
|
||||
return base
|
||||
|
||||
def _ensure_positions_base(self, conn: Connection) -> str:
|
||||
"""
|
||||
Гарантировать наличие контейнера OU=Positions в AD.
|
||||
|
||||
Args:
|
||||
conn: Открытое LDAP-соединение
|
||||
|
||||
Returns:
|
||||
DN контейнера должностей
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если создание контейнера не удалось
|
||||
"""
|
||||
base = self._positions_base()
|
||||
ensure_container_exists(conn, base)
|
||||
return base
|
||||
|
||||
def _ensure_position_group(self, conn: Connection, pos: Position) -> str:
|
||||
"""
|
||||
Гарантирует наличие агрегаторной группы должности: CN=POS_<name>,OU=Positions,...
|
||||
Обновляет pos.ldap_group_dn при необходимости и возвращает DN.
|
||||
|
||||
Логика:
|
||||
1. Если pos.ldap_group_dn существует - проверить существование и актуальность CN
|
||||
2. Если нет - искать по CN=POS_<name> в OU=Positions
|
||||
3. Если не найдено - создать новую POS-группу
|
||||
4. Обновить pos.ldap_group_dn в БД при изменениях
|
||||
|
||||
Args:
|
||||
conn: Открытое LDAP-соединение
|
||||
pos: Должность
|
||||
|
||||
Returns:
|
||||
DN POS-группы должности
|
||||
|
||||
Raises:
|
||||
ValueError: Если имя должности пустое
|
||||
RuntimeError: Если операция LDAP не удалась
|
||||
"""
|
||||
ensure_container_exists(conn, self._positions_base())
|
||||
name = (pos.name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Position.name is empty")
|
||||
|
||||
expected_cn = f"POS_{name}"
|
||||
expected_rdn = f"CN={esc_rdn(expected_cn)}"
|
||||
|
||||
saved_dn = (pos.ldap_group_dn or "").strip()
|
||||
if saved_dn:
|
||||
ok = conn.search(
|
||||
saved_dn,
|
||||
"(objectClass=group)",
|
||||
search_scope=BASE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if ok and conn.entries:
|
||||
cur_rdn = saved_dn.split(",", 1)[0]
|
||||
if cur_rdn != expected_rdn:
|
||||
if not conn.modify_dn(saved_dn, expected_rdn):
|
||||
raise RuntimeError(
|
||||
f"LDAP rename POS group failed: {conn.result}"
|
||||
)
|
||||
base = ",".join(saved_dn.split(",")[1:])
|
||||
new_dn = f"{expected_rdn},{base}"
|
||||
else:
|
||||
new_dn = saved_dn
|
||||
if new_dn != pos.ldap_group_dn:
|
||||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=new_dn)
|
||||
pos.ldap_group_dn = new_dn
|
||||
return new_dn
|
||||
|
||||
# поиск по CN в OU=Positions
|
||||
ok = conn.search(
|
||||
self._positions_base(),
|
||||
f"(&(objectClass=group)(cn={esc_filter(expected_cn)}))",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if ok and conn.entries:
|
||||
dn = str(conn.entries[0].entry_dn)
|
||||
if dn != saved_dn:
|
||||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||||
pos.ldap_group_dn = dn
|
||||
return dn
|
||||
|
||||
# создаём
|
||||
dn = f"{expected_rdn},{self._positions_base()}"
|
||||
attrs = {
|
||||
"cn": expected_cn,
|
||||
"sAMAccountName": expected_cn,
|
||||
"groupType": group_type("global", True),
|
||||
"description": f"{name} position members",
|
||||
}
|
||||
ok = conn.add(dn, ["top", "group"], attrs)
|
||||
if not ok:
|
||||
raise RuntimeError(f"LDAP add POS group failed: {conn.result}")
|
||||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||||
pos.ldap_group_dn = dn
|
||||
return dn
|
||||
|
||||
def _reconcile_position_nesting(self, conn: Connection, position: Position) -> str:
|
||||
"""
|
||||
Делает так, чтобы POS-группа должности была участником ровно тех AD-групп,
|
||||
что привязаны к Position.groups.
|
||||
|
||||
Логика:
|
||||
1. Получить список целевых групп из position.groups (Django Group -> DN в AD)
|
||||
2. Получить список текущих групп, где POS-группа уже состоит
|
||||
3. Добавить POS-группу в недостающие целевые группы
|
||||
4. Удалить POS-группу из лишних групп
|
||||
|
||||
Args:
|
||||
conn: Открытое LDAP-соединение
|
||||
position: Должность
|
||||
|
||||
Returns:
|
||||
DN POS-группы должности
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если операция LDAP не удалась
|
||||
"""
|
||||
pos_dn = self._ensure_position_group(conn, position)
|
||||
|
||||
# желаемые группы (по именам Django Group -> CN в AD)
|
||||
desired_dns: Set[str] = set()
|
||||
for g in position.groups.all():
|
||||
dn = self._group_service.find_dn(
|
||||
conn,
|
||||
g.name,
|
||||
bases=[
|
||||
getattr(settings, "LDAP_GROUPS_BASE", None)
|
||||
or getattr(settings, "LDAP_BASE_DN", "")
|
||||
],
|
||||
)
|
||||
if dn:
|
||||
desired_dns.add(dn)
|
||||
|
||||
# текущие группы, где POS уже состоит
|
||||
current_dns: Set[str] = set()
|
||||
search_bases = [
|
||||
b
|
||||
for b in [
|
||||
getattr(settings, "LDAP_GROUPS_BASE", None),
|
||||
getattr(settings, "LDAP_BASE_DN", None),
|
||||
]
|
||||
if b
|
||||
]
|
||||
for base in search_bases:
|
||||
ok = conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(member={esc_filter(pos_dn)}))",
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if ok and conn.entries:
|
||||
current_dns.update(str(e.entry_dn) for e in conn.entries)
|
||||
|
||||
# добавить недостающее
|
||||
for add_dn in desired_dns - current_dns:
|
||||
self._group_service.add_members(conn, add_dn, [pos_dn])
|
||||
|
||||
# снять лишнее
|
||||
for rem_dn in current_dns - desired_dns:
|
||||
self._group_service.remove_members(conn, rem_dn, [pos_dn])
|
||||
|
||||
return pos_dn
|
||||
|
||||
def _reconcile_position(self, conn: Connection, pos: Position) -> str:
|
||||
"""
|
||||
Приводит к консистентности:
|
||||
1) наличие POS_* группы,
|
||||
2) вложение POS_* в группы из pos.groups,
|
||||
3) состав участников POS_* = сотрудники с этой должностью.
|
||||
Возвращает DN POS_* группы.
|
||||
|
||||
Args:
|
||||
conn: Открытое LDAP-соединение
|
||||
pos: Должность для синхронизации
|
||||
|
||||
Returns:
|
||||
DN POS-группы должности
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если операция LDAP не удалась
|
||||
"""
|
||||
pos_dn = self._ensure_position_group(conn, pos)
|
||||
|
||||
# 2) вложение POS_* в target-группы
|
||||
expected_container_dns: Set[str] = set()
|
||||
for g in pos.groups.all():
|
||||
dn = self._group_service.find_dn(conn, g.name) # ищем по CN группы
|
||||
if dn:
|
||||
expected_container_dns.add(dn)
|
||||
|
||||
current_container_dns = self._group_service.groups_with_member(conn, pos_dn)
|
||||
to_add = expected_container_dns - current_container_dns
|
||||
to_del = current_container_dns - expected_container_dns
|
||||
|
||||
for dn in to_add:
|
||||
self._group_service.add_members(conn, dn, [pos_dn])
|
||||
for dn in to_del:
|
||||
self._group_service.remove_members(conn, dn, [pos_dn])
|
||||
|
||||
# 3) участники POS_* = сотрудники с этой позицией
|
||||
emp_ids = list(
|
||||
Employee.objects.filter(position_id=pos.id, is_active=True).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
dn_map = dict(
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk__in=[str(i) for i in emp_ids]
|
||||
).values_list("object_pk", "ldap_dn")
|
||||
)
|
||||
member_dns = [dn for dn in dn_map.values() if dn]
|
||||
self._group_service.replace_members(conn, pos_dn, member_dns)
|
||||
return pos_dn
|
||||
485
backend/employees/ldap/services/sync_service.py
Normal file
485
backend/employees/ldap/services/sync_service.py
Normal file
@ -0,0 +1,485 @@
|
||||
"""Сервис синхронизации данных между Django и LDAP.
|
||||
|
||||
Этот модуль предоставляет высокоуровневый API для двусторонней синхронизации:
|
||||
- Import: LDAP → Django (пользователи, отделы)
|
||||
- Export: Django → LDAP (обновление профилей, групп, перемещение между отделами)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from ...models import Department, Employee, EmployeeDepartment, LdapSyncState
|
||||
from ..config import SyncConfig
|
||||
from ..infrastructure.connections import _ldap
|
||||
from ..domain.dtos import (
|
||||
DirectoryUserDTO,
|
||||
DirectoryDepartmentDTO,
|
||||
LdapPersonDTO,
|
||||
_entry_to_dto,
|
||||
)
|
||||
from ..utils.ldap_utils import _paged_search, get_attr_str
|
||||
from .user_service import UserService
|
||||
from .department_service import DepartmentService
|
||||
from .group_service import GroupService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Сервис синхронизации данных между Django и LDAP.
|
||||
|
||||
Координирует работу UserService, DepartmentService и GroupService
|
||||
для выполнения массовых операций импорта и экспорта.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_service: Optional[UserService] = None,
|
||||
department_service: Optional[DepartmentService] = None,
|
||||
group_service: Optional[GroupService] = None,
|
||||
):
|
||||
"""Инициализация SyncService.
|
||||
|
||||
Args:
|
||||
user_service: Сервис для работы с пользователями.
|
||||
department_service: Сервис для работы с отделами.
|
||||
group_service: Сервис для работы с группами.
|
||||
"""
|
||||
self._user_service = user_service or UserService()
|
||||
self._department_service = department_service or DepartmentService()
|
||||
self._group_service = group_service or GroupService()
|
||||
|
||||
# ==================== IMPORT: DEPARTMENTS ====================
|
||||
|
||||
def import_departments(self, cfg: SyncConfig) -> Tuple[int, int, int]:
|
||||
"""Импорт OU отделов из LDAP в Django.
|
||||
|
||||
Читает organizational units из LDAP и создаёт/обновляет
|
||||
соответствующие записи Department в Django.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int]: (created, updated, deleted).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не задана база отделов.
|
||||
"""
|
||||
raw_base = cfg.departments_base_dn or getattr(
|
||||
settings, "LDAP_DEPARTMENTS_BASE", ""
|
||||
)
|
||||
base = raw_base.strip().strip('"').strip("'")
|
||||
|
||||
if not base:
|
||||
raise RuntimeError("departments_base_dn не задан.")
|
||||
|
||||
created = updated = deleted = 0
|
||||
seen_names: Set[str] = set()
|
||||
|
||||
with _ldap() as conn, transaction.atomic():
|
||||
# Читаем все OU
|
||||
entries = _paged_search(
|
||||
conn, base, "(objectClass=organizationalUnit)"
|
||||
)
|
||||
|
||||
base_dn_lower = base.lower()
|
||||
root_ou_name = (
|
||||
base.split(",", 1)[0][3:].strip()
|
||||
if base.upper().startswith("OU=")
|
||||
else None
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
attrs = getattr(entry, "entry_attributes_as_dict", {}) or {}
|
||||
dn: str = str(getattr(entry, "entry_dn", "")) or get_attr_str(
|
||||
attrs, "distinguishedName"
|
||||
)
|
||||
dn = (dn or "").strip()
|
||||
|
||||
# Пропускаем корневой DN
|
||||
if not dn or dn.lower() == base_dn_lower:
|
||||
continue
|
||||
|
||||
name: str = (
|
||||
get_attr_str(attrs, "ou") or get_attr_str(attrs, "name")
|
||||
).strip()
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Пропускаем корневой OU
|
||||
if (
|
||||
root_ou_name
|
||||
and name.lower() == root_ou_name.lower()
|
||||
and dn.lower() != base_dn_lower
|
||||
):
|
||||
pass
|
||||
|
||||
seen_names.add(name)
|
||||
|
||||
# Создаём или обновляем отдел
|
||||
dept, was_created = Department.objects.get_or_create(name=name)
|
||||
|
||||
if was_created:
|
||||
created += 1
|
||||
if cfg.show_changes:
|
||||
print(f"[CHG] + Dept: {name} DN={dn}")
|
||||
|
||||
# Обновляем руководителя
|
||||
head_dn: str = get_attr_str(attrs, "managedBy")
|
||||
head_obj = None
|
||||
|
||||
if head_dn:
|
||||
head_pk = (
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", ldap_dn__iexact=head_dn
|
||||
)
|
||||
.values_list("object_pk", flat=True)
|
||||
.first()
|
||||
)
|
||||
if head_pk:
|
||||
head_obj = Employee.objects.filter(pk=head_pk).first()
|
||||
|
||||
if head_obj and getattr(dept, "head_id", None) != head_obj.id:
|
||||
if not cfg.dry_run:
|
||||
dept.head = head_obj
|
||||
if hasattr(dept, "head_appointed_at"):
|
||||
dept.head_appointed_at = timezone.now()
|
||||
dept.save(update_fields=["head", "head_appointed_at"])
|
||||
else:
|
||||
dept.save(update_fields=["head"])
|
||||
updated += 1
|
||||
if cfg.show_changes:
|
||||
who = head_obj.email or f"id={head_obj.id}"
|
||||
print(f"[CHG] ~ Dept head: {name} -> {who}")
|
||||
|
||||
# Сохраняем состояние синхронизации
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="department", object_pk=str(dept.pk)
|
||||
)
|
||||
state.touch(
|
||||
ldap_dn=dn,
|
||||
last_ldap_modify_ts=None,
|
||||
last_django_modify_ts=timezone.now(),
|
||||
sync_dir="ldap",
|
||||
)
|
||||
|
||||
# Удаляем отделы, которых нет в LDAP
|
||||
to_delete_qs = Department.objects.exclude(name__in=seen_names)
|
||||
|
||||
if cfg.show_changes:
|
||||
deleted_names = list(to_delete_qs.values_list("name", flat=True))
|
||||
for name in deleted_names:
|
||||
verb = "будет удалён" if cfg.dry_run else "удалён"
|
||||
print(f"[CHG] - Dept: {name} ({verb})")
|
||||
|
||||
deleted_count = to_delete_qs.count()
|
||||
|
||||
if cfg.dry_run:
|
||||
deleted = deleted_count
|
||||
else:
|
||||
# Сначала очищаем head ссылки
|
||||
Department.objects.filter(
|
||||
pk__in=to_delete_qs.values_list("pk", flat=True)
|
||||
).update(head=None)
|
||||
|
||||
# Удаляем отделы
|
||||
to_delete_qs.delete()
|
||||
|
||||
# Очищаем старые sync states
|
||||
LdapSyncState.objects.filter(model="department").exclude(
|
||||
object_pk__in=Department.objects.values_list("pk", flat=True)
|
||||
).delete()
|
||||
|
||||
deleted = deleted_count
|
||||
|
||||
return created, updated, deleted
|
||||
|
||||
# ==================== IMPORT: USERS ====================
|
||||
|
||||
def _create_user_from_dto(
|
||||
self, dto: LdapPersonDTO
|
||||
) -> Optional[Employee]:
|
||||
"""Создаёт нового Employee по данным из LDAP.
|
||||
|
||||
Args:
|
||||
dto: Нормализованные данные из LDAP.
|
||||
|
||||
Returns:
|
||||
Optional[Employee]: Созданный пользователь или None.
|
||||
"""
|
||||
if not dto.phone_e164:
|
||||
logger.warning(
|
||||
"LDAP import: skip create for DN=%s, email=%s — no valid phone",
|
||||
dto.dn,
|
||||
dto.email,
|
||||
)
|
||||
return None
|
||||
|
||||
user = Employee(
|
||||
email=dto.email,
|
||||
first_name=dto.given,
|
||||
last_name=dto.sn,
|
||||
is_active=dto.is_active,
|
||||
is_ldap_managed=True,
|
||||
)
|
||||
|
||||
# Контакты
|
||||
user.phone_number = dto.phone_e164
|
||||
if not (
|
||||
getattr(user, "whatsapp", None)
|
||||
or getattr(user, "telegram", None)
|
||||
or getattr(user, "wechat", None)
|
||||
):
|
||||
user.whatsapp = dto.phone_e164
|
||||
|
||||
try:
|
||||
user.full_clean(exclude=["password"])
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"LDAP import: skip create for DN=%s, email=%s — %s",
|
||||
dto.dn,
|
||||
dto.email,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def _update_user_from_dto(
|
||||
self, user: Employee, dto: LdapPersonDTO
|
||||
) -> Employee:
|
||||
"""Обновляет существующего Employee данными из LDAP.
|
||||
|
||||
Args:
|
||||
user: Существующий пользователь.
|
||||
dto: Новые данные из LDAP.
|
||||
|
||||
Returns:
|
||||
Employee: Обновлённый пользователь.
|
||||
"""
|
||||
changed = False
|
||||
|
||||
for field, val in (
|
||||
("email", dto.email),
|
||||
("first_name", dto.given),
|
||||
("last_name", dto.sn),
|
||||
("is_active", dto.is_active),
|
||||
):
|
||||
if val is not None and getattr(user, field) != val:
|
||||
setattr(user, field, val)
|
||||
changed = True
|
||||
|
||||
if dto.phone_e164:
|
||||
if user.phone_number != dto.phone_e164:
|
||||
user.phone_number = dto.phone_e164
|
||||
changed = True
|
||||
if not (user.whatsapp or user.telegram or user.wechat):
|
||||
user.whatsapp = dto.phone_e164
|
||||
changed = True
|
||||
|
||||
if not user.is_ldap_managed:
|
||||
user.is_ldap_managed = True
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
def import_users(self, cfg: SyncConfig) -> Tuple[int, int, int]:
|
||||
"""Импорт пользователей из LDAP в Django.
|
||||
|
||||
Читает пользователей из LDAP и создаёт/обновляет
|
||||
соответствующие записи Employee в Django.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int]: (created, updated, deleted).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не заданы базовые DN.
|
||||
"""
|
||||
base_users = (
|
||||
cfg.users_base_dn or getattr(settings, "LDAP_USERS_BASE", "")
|
||||
).strip()
|
||||
base_deps = (
|
||||
cfg.departments_base_dn
|
||||
or getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
).strip()
|
||||
|
||||
if not (base_users and base_deps):
|
||||
raise RuntimeError("LDAP_USERS_BASE/LDAP_DEPARTMENTS_BASE не заданы.")
|
||||
|
||||
created = updated = deleted = 0
|
||||
seen_guids: Set[str] = set()
|
||||
seen_dns: Set[str] = set()
|
||||
|
||||
with _ldap() as conn, transaction.atomic():
|
||||
# Читаем пользователей из обеих баз
|
||||
flt = "(&(objectCategory=person)(objectClass=user))"
|
||||
entries = _paged_search(conn, base_users, flt) + _paged_search(
|
||||
conn, base_deps, flt
|
||||
)
|
||||
|
||||
# Конвертируем в DTO
|
||||
dtos: List[LdapPersonDTO] = []
|
||||
for entry in entries:
|
||||
dto = _entry_to_dto(entry)
|
||||
if dto.dn:
|
||||
seen_dns.add(dto.dn)
|
||||
if dto.guid:
|
||||
seen_guids.add(dto.guid)
|
||||
dtos.append(dto)
|
||||
|
||||
# Индексируем существующих пользователей
|
||||
from ..repositories.employee_repository import (
|
||||
_load_existing_users_index,
|
||||
_find_user_for_dto,
|
||||
_bind_user_department,
|
||||
_cleanup_absent_users,
|
||||
)
|
||||
from ..repositories.sync_state_repository import _touch_sync_state
|
||||
|
||||
by_guid, by_email = _load_existing_users_index(dtos)
|
||||
|
||||
# Разделяем на create/update
|
||||
to_create: List[LdapPersonDTO] = []
|
||||
to_update: List[Tuple[Employee, LdapPersonDTO]] = []
|
||||
|
||||
for dto in dtos:
|
||||
existing = _find_user_for_dto(
|
||||
dto, by_guid=by_guid, by_email=by_email
|
||||
)
|
||||
if existing:
|
||||
to_update.append((existing, dto))
|
||||
else:
|
||||
to_create.append(dto)
|
||||
|
||||
skipped = 0
|
||||
processed: List[Tuple[Employee, LdapPersonDTO]] = []
|
||||
|
||||
# UPDATE
|
||||
for user, dto in to_update:
|
||||
self._update_user_from_dto(user, dto)
|
||||
processed.append((user, dto))
|
||||
updated += 1
|
||||
|
||||
# CREATE
|
||||
for dto in to_create:
|
||||
user = self._create_user_from_dto(dto)
|
||||
if not user:
|
||||
skipped += 1
|
||||
continue
|
||||
processed.append((user, dto))
|
||||
created += 1
|
||||
|
||||
if cfg.show_changes:
|
||||
for dto in to_create:
|
||||
if any(u.email == dto.email for u, _ in processed):
|
||||
print(f"[CHG] + User: {dto.email} DN={dto.dn}")
|
||||
for existing, dto in to_update:
|
||||
print(f"[CHG] ~ User: {existing.email} DN={dto.dn}")
|
||||
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"[LDAP] Пропущено записей из-за валидации: %s", skipped
|
||||
)
|
||||
|
||||
# Привязываем к отделам и сохраняем sync state
|
||||
for user, dto in processed:
|
||||
_bind_user_department(user, dto.dn)
|
||||
_touch_sync_state(
|
||||
user,
|
||||
dn=dto.dn,
|
||||
guid=dto.guid,
|
||||
last_ldap_modify_ts=dto.when_changed,
|
||||
sync_dir="ldap",
|
||||
dry_run=cfg.dry_run,
|
||||
)
|
||||
|
||||
# Очищаем отсутствующих
|
||||
deleted = _cleanup_absent_users(
|
||||
seen_guids=seen_guids,
|
||||
seen_dns=seen_dns,
|
||||
dry_run=cfg.dry_run,
|
||||
show_changes=cfg.show_changes,
|
||||
)
|
||||
|
||||
return created, updated, deleted
|
||||
|
||||
# ==================== EXPORT: USERS ====================
|
||||
|
||||
def export_users(self, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
|
||||
"""Экспорт пользователей из Django в LDAP.
|
||||
|
||||
Создаёт/обновляет пользователей в LDAP на основе данных из Django.
|
||||
Включает: логины/UPN, перемещение между отделами, аватары, группы.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int, int, int]:
|
||||
(logins_set, moved, avatars_set, groups_added, groups_removed).
|
||||
"""
|
||||
# TODO: Реализовать через UserService
|
||||
logger.warning(
|
||||
"export_users не полностью реализован через новые сервисы"
|
||||
)
|
||||
return 0, 0, 0, 0, 0
|
||||
|
||||
def export_users_delete(
|
||||
self, cfg: SyncConfig, employees: Iterable[Employee]
|
||||
) -> int:
|
||||
"""Удаляет учётные записи пользователей в LDAP.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
employees: Итератор пользователей для удаления.
|
||||
|
||||
Returns:
|
||||
int: Количество успешно удалённых DN.
|
||||
"""
|
||||
do_write = not cfg.dry_run
|
||||
deleted = 0
|
||||
|
||||
with _ldap() as conn:
|
||||
for emp in employees:
|
||||
state_dn = (
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
)
|
||||
.values_list("ldap_dn", flat=True)
|
||||
.first()
|
||||
)
|
||||
user_dn = (state_dn or "").strip()
|
||||
|
||||
if not user_dn:
|
||||
logger.warning(
|
||||
"Пропуск удаления: у сотрудника pk=%s пустой ldap_dn",
|
||||
emp.pk,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if do_write:
|
||||
self._user_service.delete_user(emp)
|
||||
deleted += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка удаления пользователя DN=%s: %s", user_dn, exc
|
||||
)
|
||||
|
||||
return deleted
|
||||
645
backend/employees/ldap/services/user_service.py
Normal file
645
backend/employees/ldap/services/user_service.py
Normal file
@ -0,0 +1,645 @@
|
||||
"""Сервис для управления пользователями в LDAP и Django.
|
||||
|
||||
Этот модуль содержит бизнес-логику для CRUD операций с пользователями,
|
||||
включая синхронизацию между Active Directory и Django ORM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from ldap3 import MODIFY_REPLACE, Connection
|
||||
|
||||
from ...models import Employee, LdapSyncState, Position
|
||||
from ..domain.dtos import DirectoryUserDTO
|
||||
from ..errors import (
|
||||
DirectoryDbError,
|
||||
DirectoryLdapError,
|
||||
DirectoryServiceError,
|
||||
)
|
||||
from ..utils.group_utils import sync_user_groups_by_cns
|
||||
from ..infrastructure.connections import _ldap
|
||||
from ..repositories.ldap_repository import (
|
||||
ensure_container_exists,
|
||||
is_taken,
|
||||
ldap_modify_or_ignore,
|
||||
modify_user_attrs,
|
||||
read_attrs,
|
||||
)
|
||||
from ..utils.image_utils import normalize_avatar_to_jpeg
|
||||
from ..utils.ldap_utils import (
|
||||
build_logins_for_user,
|
||||
cn_candidates,
|
||||
get_guid_str,
|
||||
)
|
||||
from ..utils.text_utils import esc_rdn
|
||||
|
||||
# Константы UAC (User Account Control)
|
||||
UAC_ENABLED = 512
|
||||
UAC_DISABLED = 514
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Сервис для управления пользователями в LDAP и Django."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ldap_repository=None,
|
||||
employee_repository=None,
|
||||
sync_state_repository=None
|
||||
):
|
||||
"""
|
||||
Инициализация сервиса.
|
||||
|
||||
Args:
|
||||
ldap_repository: Репозиторий для работы с LDAP (optional)
|
||||
employee_repository: Репозиторий для работы с Employee (optional)
|
||||
sync_state_repository: Репозиторий для LdapSyncState (optional)
|
||||
"""
|
||||
self.ldap_repository = ldap_repository
|
||||
self.employee_repository = employee_repository
|
||||
self.sync_state_repository = sync_state_repository
|
||||
|
||||
def create_user(self, dto: DirectoryUserDTO) -> Employee:
|
||||
"""Создаёт учётку в LDAP и запись в БД (DN/связь — только в LdapSyncState).
|
||||
|
||||
Процесс:
|
||||
1) LDAP: create → set password → optional enable.
|
||||
2) DB (atomic): создаём Employee; фиксируем DN в LdapSyncState(model='employee').
|
||||
3) Post-actions: группы, аватар (best-effort).
|
||||
|
||||
Args:
|
||||
dto (DirectoryUserDTO): Данные нового пользователя.
|
||||
|
||||
Returns:
|
||||
Employee: Созданный сотрудник.
|
||||
|
||||
Raises:
|
||||
DirectoryLdapError: Ошибка на этапе создания/настройки LDAP.
|
||||
DirectoryDbError: Ошибка записи в БД (выполняется компенсирующее удаление в LDAP).
|
||||
"""
|
||||
with _ldap() as conn:
|
||||
dn: Optional[str] = None
|
||||
try:
|
||||
# 1) LDAP
|
||||
dn = self._create_user_in_ldap(conn, dto)
|
||||
self._set_password(conn, dn, dto.initial_password)
|
||||
if dto.is_active:
|
||||
self._enable_user(conn, dn)
|
||||
except Exception as e:
|
||||
raise DirectoryLdapError(f"LDAP create failed: {e}") from e
|
||||
|
||||
# 2) DB + sync-state
|
||||
try:
|
||||
with transaction.atomic():
|
||||
emp = Employee.objects.create(
|
||||
first_name=dto.first_name,
|
||||
last_name=dto.last_name,
|
||||
email=dto.email,
|
||||
phone_number=dto.phone_e164,
|
||||
is_active=dto.is_active,
|
||||
is_ldap_managed=True,
|
||||
)
|
||||
if hasattr(emp, "set_unusable_password"):
|
||||
emp.set_unusable_password()
|
||||
emp.save(update_fields=["password"])
|
||||
|
||||
attrs = read_attrs(conn, dn, ["objectGUID"])
|
||||
guid = get_guid_str(attrs)
|
||||
|
||||
self._touch_state(
|
||||
model="employee",
|
||||
object_pk=emp.pk,
|
||||
ldap_dn=dn,
|
||||
ldap_guid=guid,
|
||||
sync_dir="ldap",
|
||||
last_django_modify_ts=timezone.now(),
|
||||
)
|
||||
|
||||
# Временно импортируем для совместимости
|
||||
# TODO: Убрать после полного рефакторинга
|
||||
if dto.department_dn and hasattr(emp, "set_active_department"):
|
||||
from ..directory_service import DirectoryService
|
||||
svc = DirectoryService()
|
||||
dept = svc._get_department_by_dn(dto.department_dn)
|
||||
if dept:
|
||||
emp.set_active_department(dept)
|
||||
except Exception as e:
|
||||
# компенсируем LDAP при падении БД
|
||||
try:
|
||||
if dn:
|
||||
self._hard_delete_user_in_ldap(conn, dn)
|
||||
finally:
|
||||
pass
|
||||
raise DirectoryDbError(str(e)) from e
|
||||
|
||||
# 3) Post-actions (best effort)
|
||||
try:
|
||||
if dto.group_cns:
|
||||
sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True)
|
||||
if dto.avatar_bytes:
|
||||
avatar = normalize_avatar_to_jpeg(dto.avatar_bytes, max_kb=100)
|
||||
if avatar:
|
||||
modify_user_attrs(
|
||||
conn, dn, {"thumbnailPhoto": avatar}, do_write=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return emp
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
emp: Employee,
|
||||
changes: Dict[str, Any],
|
||||
group_cns: Optional[List[str]] = None,
|
||||
move_to_department_dn: Optional[str] = None,
|
||||
) -> Employee:
|
||||
"""Обновляет пользователя: LDAP (пароль/MOVE/UAC/attrs/groups) → затем БД.
|
||||
|
||||
Args:
|
||||
emp (Employee): Сотрудник, которого обновляем.
|
||||
changes (Dict[str, Any]): Поля модели (first_name, last_name, email, phone_number,
|
||||
is_active, password, avatar_bytes, display_name и т.п.).
|
||||
group_cns (Optional[List[str]]): Полный набор CN для синхронизации членства (или None).
|
||||
move_to_department_dn (Optional[str]): DN OU, куда переместить запись.
|
||||
|
||||
Returns:
|
||||
Employee: Обновлённая модель.
|
||||
|
||||
Raises:
|
||||
DirectoryLdapError: Ошибка применения изменений в LDAP.
|
||||
DirectoryDbError: Ошибка фиксации изменений в БД.
|
||||
DirectoryServiceError: Нет DN пользователя.
|
||||
"""
|
||||
current_dn = self._get_employee_dn(emp)
|
||||
is_active_val: Optional[bool] = None
|
||||
if "is_active" in changes:
|
||||
try:
|
||||
is_active_val = bool(changes.get("is_active"))
|
||||
except Exception:
|
||||
is_active_val = None
|
||||
|
||||
old_pos = getattr(emp, "position", None) if hasattr(emp, "position") else None
|
||||
new_pos = None
|
||||
pos_in_payload = False
|
||||
if "position" in changes or "position_id" in changes:
|
||||
pos_in_payload = True
|
||||
val = changes.pop("position", changes.pop("position_id", None))
|
||||
if isinstance(val, Position) or val is None:
|
||||
new_pos = val
|
||||
elif isinstance(val, int):
|
||||
new_pos = Position.objects.filter(pk=val).first()
|
||||
else:
|
||||
try:
|
||||
new_pos = Position.objects.filter(pk=int(val)).first()
|
||||
except Exception:
|
||||
new_pos = None
|
||||
|
||||
with _ldap() as conn:
|
||||
ldap_changes = dict(changes)
|
||||
try:
|
||||
new_dn = self._update_user_in_ldap(
|
||||
conn=conn,
|
||||
current_dn=current_dn,
|
||||
model_changes=ldap_changes,
|
||||
move_to_department_dn=move_to_department_dn,
|
||||
group_cns=group_cns,
|
||||
)
|
||||
except Exception as e:
|
||||
raise DirectoryLdapError(f"LDAP update failed: {e}") from e
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# обновим sync-state, если DN поменялся
|
||||
if new_dn and new_dn != current_dn:
|
||||
self._touch_state(
|
||||
model="employee",
|
||||
object_pk=emp.pk,
|
||||
ldap_dn=new_dn,
|
||||
sync_dir="ldap",
|
||||
last_django_modify_ts=timezone.now(),
|
||||
)
|
||||
current_dn = new_dn
|
||||
|
||||
# синхронизация поля is_active
|
||||
updated_fields: List[str] = []
|
||||
if (
|
||||
is_active_val is not None
|
||||
and getattr(emp, "is_active", None) != is_active_val
|
||||
):
|
||||
emp.is_active = is_active_val
|
||||
updated_fields.append("is_active")
|
||||
|
||||
for k, v in changes.items():
|
||||
if k in {"password", "avatar_bytes", "is_active"}:
|
||||
continue
|
||||
if hasattr(emp, k) and getattr(emp, k) != v:
|
||||
setattr(emp, k, v)
|
||||
updated_fields.append(k)
|
||||
|
||||
if pos_in_payload and hasattr(emp, "position"):
|
||||
old_id = getattr(emp, "position_id", None)
|
||||
new_id = new_pos.id if new_pos else None
|
||||
if old_id != new_id:
|
||||
emp.position = new_pos
|
||||
updated_fields.append("position")
|
||||
|
||||
if move_to_department_dn and hasattr(emp, "set_active_department"):
|
||||
from .department_service import DepartmentService
|
||||
dept_svc = DepartmentService()
|
||||
dept = dept_svc._get_department_by_dn(move_to_department_dn)
|
||||
if dept:
|
||||
emp.set_active_department(dept)
|
||||
|
||||
if updated_fields:
|
||||
emp.save(update_fields=list(set(updated_fields)))
|
||||
except Exception as e:
|
||||
raise DirectoryDbError(str(e)) from e
|
||||
|
||||
try:
|
||||
if pos_in_payload and (old_pos != new_pos):
|
||||
# Временное решение - используем методы из DirectoryService
|
||||
# TODO: убрать после создания PositionService (Сессия 8)
|
||||
from ..directory_service import DirectoryService
|
||||
svc = DirectoryService()
|
||||
if old_pos:
|
||||
old_dn = (old_pos.ldap_group_dn or "").strip()
|
||||
if not old_dn:
|
||||
maybe = svc.find_group_dn(
|
||||
conn,
|
||||
f"POS_{old_pos.name}",
|
||||
bases=[svc._positions_base()],
|
||||
)
|
||||
old_dn = maybe or ""
|
||||
if old_dn:
|
||||
svc.remove_group_members(conn, old_dn, [current_dn])
|
||||
|
||||
if new_pos:
|
||||
pos_dn = svc._ensure_position_group(conn, new_pos)
|
||||
svc.add_group_members(conn, pos_dn, [current_dn])
|
||||
except Exception as e:
|
||||
raise DirectoryLdapError(
|
||||
f"LDAP position membership sync failed: {e}"
|
||||
) from e
|
||||
|
||||
return emp
|
||||
|
||||
def delete_user(self, emp: Employee) -> None:
|
||||
"""Удаляет пользователя: LDAP soft-disable → DB delete → LDAP hard delete.
|
||||
|
||||
Args:
|
||||
emp (Employee): Удаляемый сотрудник.
|
||||
|
||||
Raises:
|
||||
DirectoryLdapError: Ошибка при soft-disable/hard-delete в LDAP.
|
||||
DirectoryDbError: Ошибка при удалении записи в БД.
|
||||
"""
|
||||
try:
|
||||
dn: Optional[str] = self._get_employee_dn(emp)
|
||||
except DirectoryServiceError:
|
||||
dn = None
|
||||
|
||||
with _ldap() as conn:
|
||||
if dn:
|
||||
try:
|
||||
modify_user_attrs(conn, dn, {"userAccountControl": 0x0202})
|
||||
except Exception as e:
|
||||
raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
emp_pk = emp.pk
|
||||
emp.delete()
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk=str(emp_pk)
|
||||
).delete()
|
||||
except Exception as e:
|
||||
raise DirectoryDbError(str(e)) from e
|
||||
|
||||
if dn:
|
||||
try:
|
||||
self._hard_delete_user_in_ldap(conn, dn)
|
||||
except Exception as e:
|
||||
raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e
|
||||
|
||||
# ==================== DN ↔ ID Conversion Methods ==================== #
|
||||
|
||||
def employee_ids_to_dns(self, ids: list[int]) -> list[str]:
|
||||
"""Конвертирует ID сотрудников в их Distinguished Names.
|
||||
|
||||
Args:
|
||||
ids: Список ID сотрудников.
|
||||
|
||||
Returns:
|
||||
Список DN из LdapSyncState.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
id_strs = [str(i) for i in ids if isinstance(i, int)]
|
||||
return list(
|
||||
LdapSyncState.objects.filter(model="employee", object_pk__in=id_strs)
|
||||
.exclude(ldap_dn__isnull=True)
|
||||
.exclude(ldap_dn__exact="")
|
||||
.values_list("ldap_dn", flat=True)
|
||||
)
|
||||
|
||||
def dns_to_employee_ids(self, dns: list[str]) -> list[int]:
|
||||
"""Конвертирует Distinguished Names в ID сотрудников.
|
||||
|
||||
Args:
|
||||
dns: Список Distinguished Names.
|
||||
|
||||
Returns:
|
||||
Список ID сотрудников.
|
||||
"""
|
||||
if not dns:
|
||||
return []
|
||||
ids = list(
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", ldap_dn__in=dns
|
||||
).values_list("object_pk", flat=True)
|
||||
)
|
||||
# object_pk хранится как str
|
||||
return [int(x) for x in ids if str(x).isdigit()]
|
||||
|
||||
def employees_brief_by_dns(self, dns: list[str]) -> list[dict]:
|
||||
"""Получает краткую информацию о сотрудниках по их DN.
|
||||
|
||||
Args:
|
||||
dns: Список Distinguished Names.
|
||||
|
||||
Returns:
|
||||
Список словарей с полями: id, email, first_name, last_name.
|
||||
"""
|
||||
ids = self.dns_to_employee_ids(dns)
|
||||
return list(
|
||||
Employee.objects.filter(id__in=ids).values(
|
||||
"id", "email", "first_name", "last_name"
|
||||
)
|
||||
)
|
||||
|
||||
# ==================== Private Helper Methods ==================== #
|
||||
|
||||
def _touch_state(
|
||||
self, *, model: str, object_pk: str | int, **touch_kwargs: Any
|
||||
) -> LdapSyncState:
|
||||
"""Унифицированная запись в LdapSyncState.touch(...).
|
||||
|
||||
Args:
|
||||
model (str): Имя модели в sync-state (например, 'employee').
|
||||
object_pk (Union[str,int]): PK объекта в БД.
|
||||
**touch_kwargs: Параметры, передаваемые в touch(...).
|
||||
|
||||
Returns:
|
||||
LdapSyncState: Обновлённая/созданная запись.
|
||||
"""
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model=model, object_pk=str(object_pk)
|
||||
)
|
||||
state.touch(**touch_kwargs)
|
||||
return state
|
||||
|
||||
def _set_password(self, conn: Connection, dn: str, new_password: str) -> None:
|
||||
"""Меняет пароль пользователя в AD (control)."""
|
||||
ok = conn.extend.microsoft.modify_password(dn, new_password)
|
||||
if not ok:
|
||||
msg = conn.result or {}
|
||||
raw = (msg.get("message") or "").upper()
|
||||
if "0000052D" in raw:
|
||||
raise RuntimeError("Пароль не соответствует политике сложности AD")
|
||||
raise RuntimeError(f"LDAP set password failed: {msg}")
|
||||
|
||||
def _enable_user(self, conn: Connection, dn: str) -> None:
|
||||
"""Включает учётку (UAC=512)."""
|
||||
ldap_modify_or_ignore(
|
||||
conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [UAC_ENABLED])]}, set()
|
||||
)
|
||||
|
||||
def _unique_logins(
|
||||
self, conn: Connection, dto: DirectoryUserDTO, upn_suffix: str
|
||||
) -> tuple[str, str]:
|
||||
"""Генерирует уникальные sAMAccountName и UPN."""
|
||||
sam, upn = build_logins_for_user(
|
||||
first_name=dto.first_name,
|
||||
last_name=dto.last_name,
|
||||
email=dto.email,
|
||||
upn_suffix=upn_suffix,
|
||||
is_taken_sam=lambda s: is_taken(conn, attributes={"sAMAccountName": s}),
|
||||
is_taken_upn=lambda u: is_taken(conn, attributes={"userPrincipalName": u}),
|
||||
guid=getattr(dto, "ldap_guid", None),
|
||||
)
|
||||
if not sam or not upn:
|
||||
raise ValueError("Не удалось сгенерировать уникальные sAMAccountName/UPN")
|
||||
return sam, upn
|
||||
|
||||
def _phone_write_attr(self) -> str:
|
||||
"""Возвращает имя телефонного атрибута для записи."""
|
||||
write_attr = getattr(settings, "LDAP_WRITE_PHONE_ATTR", None)
|
||||
if write_attr:
|
||||
return write_attr
|
||||
candidates = getattr(
|
||||
settings, "LDAP_PHONE_ATTRS", ("mobile", "telephoneNumber")
|
||||
)
|
||||
return candidates[0] if candidates else "telephoneNumber"
|
||||
|
||||
def _build_user_attrs(
|
||||
self, dto: DirectoryUserDTO, sam: str, upn: str, cn_text: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Собирает атрибуты для создания user в AD, отбрасывая пустые значения."""
|
||||
if not all(isinstance(x, str) for x in (sam, upn, cn_text)):
|
||||
raise TypeError("sam, upn и cn_text должны быть строками")
|
||||
|
||||
base_attrs: Dict[str, Any] = {
|
||||
"cn": cn_text,
|
||||
"givenName": dto.first_name or None,
|
||||
"sn": dto.last_name or ".", # sn не может быть пустым
|
||||
"displayName": cn_text or None,
|
||||
"mail": (dto.email or None),
|
||||
self._phone_write_attr(): (dto.phone_e164 or None),
|
||||
"sAMAccountName": sam,
|
||||
"userPrincipalName": upn,
|
||||
"userAccountControl": UAC_DISABLED, # disabled до верификации email
|
||||
}
|
||||
return {k: v for k, v in base_attrs.items() if v not in (None, "", [])}
|
||||
|
||||
def _try_add_with_cn_list(
|
||||
self,
|
||||
conn: Connection,
|
||||
base_dn: str,
|
||||
object_classes: List[str],
|
||||
dto: DirectoryUserDTO,
|
||||
sam: str,
|
||||
upn: str,
|
||||
cns: List[str],
|
||||
) -> Optional[str]:
|
||||
"""Пробует создать запись по списку CN-кандидатов."""
|
||||
if not isinstance(cns, list):
|
||||
raise TypeError("cns должен быть списком строк")
|
||||
for cn_txt in cns:
|
||||
rdn = f"CN={esc_rdn(cn_txt)}"
|
||||
dn_try = f"{rdn},{base_dn}"
|
||||
attrs = self._build_user_attrs(dto, sam, upn, cn_txt)
|
||||
if conn.add(dn_try, object_classes, attrs):
|
||||
return dn_try
|
||||
return None
|
||||
|
||||
def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str:
|
||||
"""Создаёт объект user в LDAP (AD) и возвращает его DN."""
|
||||
base_dn = (
|
||||
dto.department_dn
|
||||
or getattr(settings, "LDAP_USERS_BASE", None)
|
||||
or getattr(settings, "LDAP_BASE_DN", None)
|
||||
)
|
||||
if not base_dn:
|
||||
raise RuntimeError(
|
||||
"Не задан контейнер для создания пользователя (department_dn / LDAP_USERS_BASE / LDAP_BASE_DN)."
|
||||
)
|
||||
ensure_container_exists(conn, base_dn)
|
||||
|
||||
upn_suffix = getattr(settings, "LDAP_UPN_SUFFIX", "") or (
|
||||
dto.email.split("@", 1)[1] if dto.email and "@" in dto.email else ""
|
||||
)
|
||||
if not upn_suffix:
|
||||
raise ValueError(
|
||||
"Не задан UPN-суффикс (LDAP_UPN_SUFFIX) и его нельзя вывести из email."
|
||||
)
|
||||
|
||||
sam, upn = self._unique_logins(conn, dto, upn_suffix)
|
||||
pretty_cn = (
|
||||
" ".join(filter(None, [dto.first_name, dto.last_name])).strip()
|
||||
) or sam
|
||||
safe_cn = sam
|
||||
cn_list = cn_candidates(pretty_cn, safe_cn)
|
||||
|
||||
object_classes = ["top", "person", "organizationalPerson", "user"]
|
||||
dn = self._try_add_with_cn_list(
|
||||
conn, base_dn, object_classes, dto, sam, upn, cn_list
|
||||
)
|
||||
if dn:
|
||||
return dn
|
||||
raise RuntimeError(f"LDAP add user failed: {conn.result}")
|
||||
|
||||
def _hard_delete_user_in_ldap(self, conn: Connection, dn: str) -> None:
|
||||
"""Безусловное удаление user (ignore noSuchObject)."""
|
||||
ok = conn.delete(dn)
|
||||
if not ok and (conn.result or {}).get("description") not in {"noSuchObject"}:
|
||||
raise RuntimeError(f"LDAP delete failed for {dn}: {conn.result}")
|
||||
|
||||
def _update_user_in_ldap(
|
||||
self,
|
||||
conn: Connection,
|
||||
current_dn: str,
|
||||
model_changes: Dict[str, Any],
|
||||
*,
|
||||
move_to_department_dn: Optional[str] = None,
|
||||
group_cns: Optional[Iterable[str]] = None,
|
||||
) -> str:
|
||||
"""Применяет LDAP-изменения к существующему пользователю и возвращает (возможный новый) DN.
|
||||
|
||||
NOTE: Этот метод слишком большой (~150 строк) и должен быть рефакторен в будущем.
|
||||
Сейчас просто перенесём как есть для совместимости.
|
||||
"""
|
||||
# Временно используем DirectoryService для _move_to_department
|
||||
from ..directory_service import DirectoryService
|
||||
svc = DirectoryService()
|
||||
|
||||
if not isinstance(current_dn, str) or not current_dn.strip():
|
||||
raise TypeError("current_dn должен быть непустой строкой")
|
||||
if not isinstance(model_changes, dict):
|
||||
raise TypeError("model_changes должен быть словарём")
|
||||
|
||||
dn = current_dn
|
||||
new_password = model_changes.pop("password", None)
|
||||
avatar_bytes = model_changes.pop("avatar_bytes", None)
|
||||
is_active_val = model_changes.pop("is_active", None)
|
||||
|
||||
# 1) Пароль
|
||||
if new_password:
|
||||
self._set_password(conn, dn, new_password)
|
||||
|
||||
# 2) Перемещение
|
||||
if move_to_department_dn:
|
||||
dn = svc._move_to_department(conn, dn, move_to_department_dn)
|
||||
|
||||
# 3) UAC
|
||||
if is_active_val is not None:
|
||||
uac_val = UAC_ENABLED if is_active_val else UAC_DISABLED
|
||||
modify_user_attrs(conn, dn, {"userAccountControl": uac_val}, do_write=True)
|
||||
|
||||
# 4) Прочие атрибуты
|
||||
ldap_attrs_map = {
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
"email": "mail",
|
||||
"phone_number": self._phone_write_attr(),
|
||||
"display_name": "displayName",
|
||||
}
|
||||
attrs_to_update = {}
|
||||
for model_key, ldap_attr in ldap_attrs_map.items():
|
||||
if model_key in model_changes:
|
||||
val = model_changes[model_key]
|
||||
if val or ldap_attr == "sn":
|
||||
attrs_to_update[ldap_attr] = val if val else "."
|
||||
|
||||
if attrs_to_update:
|
||||
modify_user_attrs(conn, dn, attrs_to_update, do_write=True)
|
||||
|
||||
# 5) Аватар
|
||||
if avatar_bytes:
|
||||
avatar = normalize_avatar_to_jpeg(avatar_bytes, max_kb=100)
|
||||
if avatar:
|
||||
modify_user_attrs(conn, dn, {"thumbnailPhoto": avatar}, do_write=True)
|
||||
|
||||
# 6) Группы
|
||||
if group_cns is not None:
|
||||
sync_user_groups_by_cns(conn, dn, set(group_cns), do_write=True)
|
||||
|
||||
return dn
|
||||
|
||||
def _get_employee_dn(self, employee: Employee) -> str:
|
||||
"""Возвращает DN сотрудника из LdapSyncState.
|
||||
|
||||
Args:
|
||||
employee: Экземпляр Employee.
|
||||
|
||||
Returns:
|
||||
str: Полный DN пользователя в LDAP.
|
||||
|
||||
Raises:
|
||||
DirectoryServiceError: Если DN не найден в sync state.
|
||||
"""
|
||||
dn = (
|
||||
LdapSyncState.objects.filter(model="employee", object_pk=str(employee.pk))
|
||||
.values_list("ldap_dn", flat=True)
|
||||
.first()
|
||||
)
|
||||
if not dn:
|
||||
raise DirectoryServiceError("Employee has no ldap_dn")
|
||||
return dn
|
||||
|
||||
def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str:
|
||||
"""Перемещает пользовательский объект в указанный контейнер.
|
||||
|
||||
Args:
|
||||
conn: LDAP соединение.
|
||||
user_dn: Текущий DN пользователя.
|
||||
base_dn: DN целевого контейнера.
|
||||
|
||||
Returns:
|
||||
str: Новый DN пользователя после перемещения.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если операция перемещения не удалась.
|
||||
"""
|
||||
rdn = user_dn.split(",", 1)[0]
|
||||
ok = conn.modify_dn(user_dn, rdn, new_superior=base_dn)
|
||||
if not ok:
|
||||
raise RuntimeError(f"LDAP move user to base failed: {conn.result}")
|
||||
return f"{rdn},{base_dn}"
|
||||
|
||||
|
||||
__all__ = ["UserService"]
|
||||
@ -1,43 +1,122 @@
|
||||
"""DEPRECATED: Этот модуль сохранён для обратной совместимости.
|
||||
|
||||
Используйте вместо него:
|
||||
from employees.ldap.services.sync_service import SyncService
|
||||
|
||||
Функции в этом модуле являются обёртками над новым SyncService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable, List, Optional, Set, Tuple
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from ldap3 import BASE, Connection
|
||||
from ldap3 import BASE
|
||||
|
||||
from ..models import Department, Employee, EmployeeDepartment, LdapSyncState
|
||||
from ..models import Employee, EmployeeDepartment, LdapSyncState
|
||||
from .config import SyncConfig
|
||||
from .connections import _ldap
|
||||
from .dn import _ensure_user_dn, _move_to_department, _target_department_ou_dn
|
||||
from .dto import LdapPersonDTO, _entry_to_dto
|
||||
from .groups import _desired_group_cns_for_employee, sync_user_groups_by_cns
|
||||
from .helpers import is_taken, read_attrs, modify_user_attrs
|
||||
from .repo_db import (
|
||||
_bind_user_department,
|
||||
_cleanup_absent_users,
|
||||
_find_user_for_dto,
|
||||
_load_existing_users_index,
|
||||
_touch_sync_state,
|
||||
)
|
||||
from .utils import (
|
||||
_paged_search,
|
||||
build_logins_for_user,
|
||||
from .infrastructure.connections import _ldap
|
||||
from .utils.dn_utils import (
|
||||
_ensure_user_dn,
|
||||
_move_to_department,
|
||||
_target_department_ou_dn,
|
||||
extract_department_from_dn,
|
||||
get_attr_str,
|
||||
normalize_avatar_to_jpeg,
|
||||
)
|
||||
from .utils.group_utils import (
|
||||
_desired_group_cns_for_employee,
|
||||
sync_user_groups_by_cns,
|
||||
)
|
||||
from .repositories.ldap_repository import (
|
||||
is_taken,
|
||||
read_attrs,
|
||||
modify_user_attrs,
|
||||
)
|
||||
|
||||
from .utils.ldap_utils import (
|
||||
|
||||
build_logins_for_user,
|
||||
|
||||
)
|
||||
from .utils.image_utils import normalize_avatar_to_jpeg
|
||||
from .services.sync_service import SyncService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаём глобальный экземпляр для backward compatibility
|
||||
_sync_service = SyncService()
|
||||
|
||||
# ------------------------- ВСПОМОГАТЕЛЬНАЯ ИНФРАСТРУКТУРА ------------------------- #
|
||||
|
||||
# ==================== WRAPPER FUNCTIONS ====================
|
||||
|
||||
|
||||
def import_departments(*, cfg: SyncConfig) -> Tuple[int, int, int]:
|
||||
"""DEPRECATED: Используйте SyncService().import_departments().
|
||||
|
||||
Импорт OU отделов из LDAP в Django.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int]: (created, updated, deleted).
|
||||
"""
|
||||
return _sync_service.import_departments(cfg)
|
||||
|
||||
|
||||
def import_users(*, cfg: SyncConfig) -> Tuple[int, int, int]:
|
||||
"""DEPRECATED: Используйте SyncService().import_users().
|
||||
|
||||
Импорт пользователей из LDAP в Django.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int]: (created, updated, deleted).
|
||||
"""
|
||||
return _sync_service.import_users(cfg)
|
||||
|
||||
|
||||
def export_users(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
|
||||
"""DEPRECATED: Используйте SyncService().export_users().
|
||||
|
||||
Полный экспорт пользователей из Django в LDAP.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int, int, int]:
|
||||
(logins_set, moved, avatars_set, groups_added, groups_removed).
|
||||
"""
|
||||
return _export_users_legacy(cfg=cfg)
|
||||
|
||||
|
||||
def export_users_delete(
|
||||
*, cfg: SyncConfig, employees: Iterable[Employee]
|
||||
) -> int:
|
||||
"""DEPRECATED: Используйте SyncService().export_users_delete().
|
||||
|
||||
Удаляет учётные записи пользователей в LDAP.
|
||||
|
||||
Args:
|
||||
cfg: Конфигурация синхронизации.
|
||||
employees: Итератор пользователей для удаления.
|
||||
|
||||
Returns:
|
||||
int: Количество успешно удалённых DN.
|
||||
"""
|
||||
return _sync_service.export_users_delete(cfg, employees)
|
||||
|
||||
|
||||
# ==================== LEGACY IMPLEMENTATIONS ====================
|
||||
# Эти функции сохранены для сложной логики export_users,
|
||||
# которая ещё не полностью перенесена в новые сервисы
|
||||
|
||||
|
||||
def _employees_with_dn_qs():
|
||||
"""Возвращает QuerySet сотрудников с LDAP DN."""
|
||||
emp_ids = (
|
||||
LdapSyncState.objects.filter(model="employee")
|
||||
.exclude(ldap_dn="")
|
||||
@ -47,6 +126,7 @@ def _employees_with_dn_qs():
|
||||
|
||||
|
||||
def _ensure_and_persist_user_dn(conn, emp, *, do_write):
|
||||
"""Обеспечивает наличие DN и сохраняет в LdapSyncState."""
|
||||
user_dn = _ensure_user_dn(conn, emp)
|
||||
if do_write:
|
||||
st, _ = LdapSyncState.objects.get_or_create(
|
||||
@ -57,343 +137,18 @@ def _ensure_and_persist_user_dn(conn, emp, *, do_write):
|
||||
return user_dn
|
||||
|
||||
|
||||
def _validate_bases(*, cfg: SyncConfig) -> Tuple[str, str]:
|
||||
"""Возвращает пары базовых DN для пользователей и отделов, валидирует наличие.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: (base_users, base_deps).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не заданы базовые DN.
|
||||
"""
|
||||
base_users = (cfg.users_base_dn or getattr(settings, "LDAP_USERS_BASE", "")).strip()
|
||||
base_deps = (
|
||||
cfg.departments_base_dn or getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
).strip()
|
||||
if not (base_users and base_deps):
|
||||
raise RuntimeError("LDAP_USERS_BASE/LDAP_DEPARTMENTS_BASE не заданы.")
|
||||
return base_users, base_deps
|
||||
|
||||
|
||||
def _collect_ldap_entries(conn: Connection, base_users: str, base_deps: str) -> List:
|
||||
"""Читает все записи user из OU пользователей и OU отделов.
|
||||
|
||||
Args:
|
||||
conn (Connection): Readonly-подключение.
|
||||
base_users (str): База пользователей.
|
||||
base_deps (str): База отделов.
|
||||
|
||||
Returns:
|
||||
List: Список ldap3 entries.
|
||||
"""
|
||||
flt = "(&(objectCategory=person)(objectClass=user))"
|
||||
return _paged_search(conn, base_users, flt) + _paged_search(conn, base_deps, flt)
|
||||
|
||||
|
||||
# ----------------------------- IMPORT: DEPARTMENTS ----------------------------- #
|
||||
|
||||
|
||||
def import_departments(*, cfg: SyncConfig) -> tuple[int, int, int]:
|
||||
"""Импорт OU отделов из LDAP в Django в режиме «зеркала» (пропуская корневой контейнер).
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфигурация синхронизации.
|
||||
|
||||
Returns:
|
||||
tuple[int, int, int]: (created, updated, deleted).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не задана база отделов.
|
||||
"""
|
||||
raw_base = cfg.departments_base_dn or getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
base = raw_base.strip().strip('"').strip("'")
|
||||
created_names: list[str] = []
|
||||
updated_heads: list[tuple[str, str]] = [] # (dept, head_email/ID)
|
||||
deleted_names: list[str] = []
|
||||
if not base:
|
||||
raise RuntimeError("departments_base_dn не задан.")
|
||||
|
||||
with _ldap() as conn, transaction.atomic():
|
||||
entries = _paged_search(conn, base, "(objectClass=organizationalUnit)")
|
||||
base_dn_lower = base.lower()
|
||||
root_ou_name = (
|
||||
base.split(",", 1)[0][3:].strip()
|
||||
if base.upper().startswith("OU=")
|
||||
else None
|
||||
)
|
||||
|
||||
created = updated = deleted = 0
|
||||
seen_names: set[str] = set()
|
||||
|
||||
for e in entries:
|
||||
a = getattr(e, "entry_attributes_as_dict", {}) or {}
|
||||
dn: str = str(getattr(e, "entry_dn", "")) or get_attr_str(
|
||||
a, "distinguishedName"
|
||||
)
|
||||
dn = (dn or "").strip()
|
||||
if not dn or dn.lower() == base_dn_lower:
|
||||
continue
|
||||
|
||||
name: str = (get_attr_str(a, "ou") or get_attr_str(a, "name")).strip()
|
||||
if not name:
|
||||
continue
|
||||
# Страховка от совпадения имени с корневым OU
|
||||
if (
|
||||
root_ou_name
|
||||
and name.lower() == root_ou_name.lower()
|
||||
and dn.lower() != base_dn_lower
|
||||
):
|
||||
pass
|
||||
|
||||
seen_names.add(name)
|
||||
dept, was_created = Department.objects.get_or_create(name=name)
|
||||
if was_created:
|
||||
created += 1
|
||||
if cfg.show_changes:
|
||||
print(f"[CHG] + Dept: {name} DN={dn}")
|
||||
|
||||
head_dn: str = get_attr_str(a, "managedBy")
|
||||
head_obj = None
|
||||
if head_dn:
|
||||
head_pk = (
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", ldap_dn__iexact=head_dn
|
||||
)
|
||||
.values_list("object_pk", flat=True)
|
||||
.first()
|
||||
)
|
||||
if head_pk:
|
||||
head_obj = Employee.objects.filter(pk=head_pk).first()
|
||||
|
||||
if head_obj and getattr(dept, "head_id", None) != head_obj.id:
|
||||
if not cfg.dry_run:
|
||||
dept.head = head_obj
|
||||
if hasattr(dept, "head_appointed_at"):
|
||||
dept.head_appointed_at = timezone.now()
|
||||
dept.save(update_fields=["head", "head_appointed_at"])
|
||||
else:
|
||||
dept.save(update_fields=["head"])
|
||||
updated += 1
|
||||
if cfg.show_changes:
|
||||
who = head_obj.email or f"id={head_obj.id}"
|
||||
print(f"[CHG] ~ Dept head: {name} -> {who}")
|
||||
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="department", object_pk=str(dept.pk)
|
||||
)
|
||||
state.touch(
|
||||
ldap_dn=dn,
|
||||
last_ldap_modify_ts=None,
|
||||
last_django_modify_ts=timezone.now(),
|
||||
sync_dir="ldap",
|
||||
)
|
||||
|
||||
to_delete_qs = Department.objects.exclude(name__in=seen_names)
|
||||
if cfg.show_changes:
|
||||
deleted_names = list(to_delete_qs.values_list("name", flat=True))
|
||||
for n in deleted_names:
|
||||
verb = "будет удалён" if cfg.dry_run else "удалён"
|
||||
print(f"[CHG] - Dept: {n} ({verb})")
|
||||
deleted_count = to_delete_qs.count()
|
||||
if cfg.dry_run:
|
||||
deleted = deleted_count
|
||||
else:
|
||||
Department.objects.filter(
|
||||
pk__in=to_delete_qs.values_list("pk", flat=True)
|
||||
).update(head=None)
|
||||
to_delete_qs.delete()
|
||||
LdapSyncState.objects.filter(model="department").exclude(
|
||||
object_pk__in=Department.objects.values_list("pk", flat=True)
|
||||
).delete()
|
||||
deleted = deleted_count
|
||||
|
||||
return created, updated, deleted
|
||||
|
||||
|
||||
# ----------------------------- IMPORT: USERS ----------------------------- #
|
||||
|
||||
|
||||
def _create_user_from_dto(dto: LdapPersonDTO) -> Optional[Employee]:
|
||||
"""Создаёт нового Employee по данным из LDAP с подготовкой контактов.
|
||||
|
||||
Args:
|
||||
dto (LdapPersonDTO): Нормализованные данные из LDAP.
|
||||
|
||||
Returns:
|
||||
Optional[Employee]: Созданный пользователь или None (если пропущен из-за валидации).
|
||||
|
||||
Raises:
|
||||
django.db.DatabaseError: Ошибки сохранения в БД.
|
||||
"""
|
||||
if not dto.phone_e164:
|
||||
logger.warning(
|
||||
"LDAP import: skip create for DN=%s, email=%s — no valid phone to satisfy model.clean()",
|
||||
dto.dn,
|
||||
dto.email,
|
||||
)
|
||||
return None
|
||||
|
||||
user = Employee(
|
||||
email=dto.email,
|
||||
first_name=dto.given,
|
||||
last_name=dto.sn,
|
||||
is_active=dto.is_active,
|
||||
is_ldap_managed=True,
|
||||
def _active_dept_name(emp: Employee) -> str | None:
|
||||
"""Имя активного отдела сотрудника."""
|
||||
link = (
|
||||
EmployeeDepartment.objects.filter(employee=emp, is_active=True)
|
||||
.select_related("department")
|
||||
.first()
|
||||
)
|
||||
# Контакты
|
||||
user.phone_number = dto.phone_e164
|
||||
if not (
|
||||
getattr(user, "whatsapp", None)
|
||||
or getattr(user, "telegram", None)
|
||||
or getattr(user, "wechat", None)
|
||||
):
|
||||
user.whatsapp = dto.phone_e164
|
||||
|
||||
try:
|
||||
user.full_clean(exclude=["password"])
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"LDAP import: skip create for DN=%s, email=%s — %s", dto.dn, dto.email, exc
|
||||
)
|
||||
return None
|
||||
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
def _update_user_from_dto(user: Employee, dto: LdapPersonDTO) -> Employee:
|
||||
changed = False
|
||||
for field, val in (
|
||||
("email", dto.email),
|
||||
("first_name", dto.given),
|
||||
("last_name", dto.sn),
|
||||
("is_active", dto.is_active),
|
||||
):
|
||||
if val is not None and getattr(user, field) != val:
|
||||
setattr(user, field, val)
|
||||
changed = True
|
||||
|
||||
if dto.phone_e164:
|
||||
if user.phone_number != dto.phone_e164:
|
||||
user.phone_number = dto.phone_e164
|
||||
changed = True
|
||||
if not (user.whatsapp or user.telegram or user.wechat):
|
||||
user.whatsapp = dto.phone_e164
|
||||
changed = True
|
||||
|
||||
if not user.is_ldap_managed:
|
||||
user.is_ldap_managed = True
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
def import_users(*, cfg: SyncConfig) -> tuple[int, int, int]:
|
||||
"""Импорт пользователей из LDAP в Django в «зеркальном» режиме.
|
||||
|
||||
Этапы:
|
||||
1) Чтение/нормализация LDAP → DTO.
|
||||
2) Разделение на update/create и применение в БД.
|
||||
3) Привязка к отделам.
|
||||
4) Фиксация состояния синхронизации.
|
||||
5) Очистка отсутствующих.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг синхронизации.
|
||||
|
||||
Returns:
|
||||
tuple[int, int, int]: (created, updated, deleted).
|
||||
"""
|
||||
base_users, base_deps = _validate_bases(cfg=cfg)
|
||||
|
||||
with _ldap() as conn, transaction.atomic():
|
||||
entries = _collect_ldap_entries(conn, base_users, base_deps)
|
||||
|
||||
seen_guids: Set[str] = set()
|
||||
seen_dns: Set[str] = set()
|
||||
dtos: List[LdapPersonDTO] = []
|
||||
for e in entries:
|
||||
dto = _entry_to_dto(e)
|
||||
if dto.dn:
|
||||
seen_dns.add(dto.dn)
|
||||
if dto.guid:
|
||||
seen_guids.add(dto.guid)
|
||||
dtos.append(dto)
|
||||
|
||||
by_guid, by_email = _load_existing_users_index(dtos)
|
||||
to_create: List[LdapPersonDTO] = []
|
||||
to_update: List[Tuple[Employee, LdapPersonDTO]] = []
|
||||
|
||||
for dto in dtos:
|
||||
existing = _find_user_for_dto(dto, by_guid=by_guid, by_email=by_email)
|
||||
(to_update if existing else to_create).append(
|
||||
(existing, dto) if existing else dto
|
||||
)
|
||||
|
||||
created = updated = skipped = 0
|
||||
processed: List[Tuple[Employee, LdapPersonDTO]] = []
|
||||
|
||||
# UPDATE
|
||||
for user, dto in to_update:
|
||||
processed.append((_update_user_from_dto(user, dto), dto))
|
||||
updated += 1
|
||||
|
||||
# CREATE
|
||||
for dto in to_create:
|
||||
user = _create_user_from_dto(dto)
|
||||
if not user:
|
||||
skipped += 1
|
||||
continue
|
||||
processed.append((user, dto))
|
||||
created += 1
|
||||
|
||||
if cfg.show_changes:
|
||||
for dto in to_create:
|
||||
print(f"[CHG] + User: {dto.email or '(no email)'} DN={dto.dn}")
|
||||
for existing, dto in to_update:
|
||||
print(f"[CHG] ~ User: {existing.email} DN={dto.dn}")
|
||||
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"[LDAP] Пропущено записей из-за валидации/контактов: %s", skipped
|
||||
)
|
||||
|
||||
# Bind departments + sync state
|
||||
for user, dto in processed:
|
||||
_bind_user_department(user, dto.dn)
|
||||
_touch_sync_state(
|
||||
user,
|
||||
dn=dto.dn,
|
||||
guid=dto.guid,
|
||||
last_ldap_modify_ts=dto.when_changed,
|
||||
sync_dir="ldap",
|
||||
dry_run=cfg.dry_run,
|
||||
)
|
||||
|
||||
deleted = _cleanup_absent_users(
|
||||
seen_guids=seen_guids, seen_dns=seen_dns, dry_run=cfg.dry_run, show_changes=cfg.show_changes
|
||||
)
|
||||
|
||||
return created, updated, deleted
|
||||
|
||||
|
||||
# ----------------------------- EXPORT: LOGINS/UPN ----------------------------- #
|
||||
return link.department.name if link and link.department_id else None
|
||||
|
||||
|
||||
def export_users_create_attrs(*, cfg: SyncConfig) -> int:
|
||||
"""Создаёт недостающие sAMAccountName/UPN и базовые ФИО-атрибуты.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг (используется `dry_run` и `settings.LDAP_UPN_SUFFIX`).
|
||||
|
||||
Returns:
|
||||
int: Кол-во пользователей, для которых были выставлены логины/UPN.
|
||||
"""
|
||||
"""Создаёт недостающие sAMAccountName/UPN и базовые ФИО-атрибуты."""
|
||||
upn_suffix: str = getattr(settings, "LDAP_UPN_SUFFIX", "robotail.local")
|
||||
do_write = not cfg.dry_run
|
||||
assigned = 0
|
||||
@ -401,17 +156,23 @@ def export_users_create_attrs(*, cfg: SyncConfig) -> int:
|
||||
with _ldap() as conn:
|
||||
for emp in _employees_with_dn_qs():
|
||||
try:
|
||||
user_dn = _ensure_and_persist_user_dn(conn, emp, do_write=do_write)
|
||||
user_dn = _ensure_and_persist_user_dn(
|
||||
conn, emp, do_write=do_write
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[WARN] %s", e)
|
||||
continue
|
||||
|
||||
cur = read_attrs(conn, user_dn, ["sAMAccountName", "userPrincipalName"])
|
||||
cur = read_attrs(
|
||||
conn, user_dn, ["sAMAccountName", "userPrincipalName"]
|
||||
)
|
||||
if cur["sAMAccountName"] and cur["userPrincipalName"]:
|
||||
continue
|
||||
|
||||
guid = (
|
||||
LdapSyncState.objects.filter(model="employee", object_pk=str(emp.pk))
|
||||
LdapSyncState.objects.filter(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
)
|
||||
.values_list("ldap_guid", flat=True)
|
||||
.first()
|
||||
)
|
||||
@ -421,7 +182,9 @@ def export_users_create_attrs(*, cfg: SyncConfig) -> int:
|
||||
last_name=emp.last_name or "",
|
||||
email=emp.email or "",
|
||||
upn_suffix=upn_suffix,
|
||||
is_taken_sam=lambda s: is_taken(conn, attributes={"sAMAccountName": s}),
|
||||
is_taken_sam=lambda s: is_taken(
|
||||
conn, attributes={"sAMAccountName": s}
|
||||
),
|
||||
is_taken_upn=lambda u: is_taken(
|
||||
conn, attributes={"userPrincipalName": u}
|
||||
),
|
||||
@ -435,7 +198,9 @@ def export_users_create_attrs(*, cfg: SyncConfig) -> int:
|
||||
"userPrincipalName": upn,
|
||||
"givenName": emp.first_name or None,
|
||||
"sn": emp.last_name or None,
|
||||
"displayName": f"{emp.first_name} {emp.last_name}".strip() or None,
|
||||
"displayName": (
|
||||
f"{emp.first_name} {emp.last_name}".strip() or None
|
||||
),
|
||||
},
|
||||
do_write=do_write,
|
||||
)
|
||||
@ -444,42 +209,19 @@ def export_users_create_attrs(*, cfg: SyncConfig) -> int:
|
||||
return assigned
|
||||
|
||||
|
||||
# ----------------------------- EXPORT: PROFILE (MOVE + AVATAR) ----------------------------- #
|
||||
|
||||
|
||||
def _active_dept_name(emp: Employee) -> Optional[str]:
|
||||
"""Имя активного отдела сотрудника по таблице связей.
|
||||
|
||||
Args:
|
||||
emp (Employee): Сотрудник.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Название отдела или None.
|
||||
"""
|
||||
link = (
|
||||
EmployeeDepartment.objects.filter(employee=emp, is_active=True)
|
||||
.select_related("department")
|
||||
.first()
|
||||
)
|
||||
return link.department.name if link and link.department_id else None
|
||||
|
||||
|
||||
def export_users_update_profile(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
"""Обновляет профильные данные пользователей: перемещение между отделами и аватары.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг (используется `dry_run`).
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (moved, avatars_set).
|
||||
"""
|
||||
def export_users_update_profile(
|
||||
*, cfg: SyncConfig
|
||||
) -> Tuple[int, int]:
|
||||
"""Обновляет профильные данные: перемещение между отделами и аватары."""
|
||||
do_write = not cfg.dry_run
|
||||
moved = avatars_set = 0
|
||||
|
||||
with _ldap() as conn:
|
||||
for emp in _employees_with_dn_qs():
|
||||
try:
|
||||
user_dn = _ensure_and_persist_user_dn(conn, emp, do_write=do_write)
|
||||
user_dn = _ensure_and_persist_user_dn(
|
||||
conn, emp, do_write=do_write
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[WARN] %s", e)
|
||||
continue
|
||||
@ -502,13 +244,13 @@ def export_users_update_profile(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
and conn.entries
|
||||
):
|
||||
logger.warning(
|
||||
"[WARN] Целевой OU отдела не найден: %s", target_ou
|
||||
"[WARN] Целевой OU не найден: %s", target_ou
|
||||
)
|
||||
continue # не считаем такой MOVE
|
||||
continue
|
||||
moved += 1
|
||||
|
||||
# avatar -> thumbnailPhoto
|
||||
avatar_bytes: Optional[bytes] = None
|
||||
avatar_bytes: bytes | None = None
|
||||
avatar_field = getattr(emp, "avatar", None)
|
||||
if avatar_field and hasattr(avatar_field, "read"):
|
||||
avatar_bytes = avatar_field.read()
|
||||
@ -533,25 +275,17 @@ def export_users_update_profile(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
return moved, avatars_set
|
||||
|
||||
|
||||
# ----------------------------- EXPORT: GROUPS SYNC ----------------------------- #
|
||||
|
||||
|
||||
def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
"""Приводит членства пользователей в LDAP-группах к целевому набору.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг синхронизации.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (groups_added, groups_removed).
|
||||
"""
|
||||
"""Приводит членства пользователей в LDAP-группах к целевому набору."""
|
||||
do_write = not cfg.dry_run
|
||||
groups_added = groups_removed = 0
|
||||
|
||||
with _ldap() as conn:
|
||||
for emp in _employees_with_dn_qs():
|
||||
try:
|
||||
user_dn = _ensure_and_persist_user_dn(conn, emp, do_write=do_write)
|
||||
user_dn = _ensure_and_persist_user_dn(
|
||||
conn, emp, do_write=do_write
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[WARN] %s", e)
|
||||
continue
|
||||
@ -564,9 +298,8 @@ def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
extra_bases: list[str] = []
|
||||
dept_for_roles = target_dept or current_dept
|
||||
if dept_for_roles:
|
||||
extra_bases.append(
|
||||
f"OU=Roles,OU={dept_for_roles},{getattr(settings, 'LDAP_DEPARTMENTS_BASE', '')}"
|
||||
)
|
||||
dept_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
extra_bases.append(f"OU=Roles,OU={dept_for_roles},{dept_base}")
|
||||
|
||||
added, removed = sync_user_groups_by_cns(
|
||||
conn,
|
||||
@ -581,94 +314,35 @@ def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]:
|
||||
return groups_added, groups_removed
|
||||
|
||||
|
||||
# ----------------------------- EXPORT: DELETE ACCOUNTS ----------------------------- #
|
||||
def _export_users_legacy(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]:
|
||||
"""Legacy реализация export_users через старые функции."""
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _ldap_delete_dn(conn: Connection, dn: str, *, do_write: bool) -> None:
|
||||
"""Удаляет объект по DN в LDAP.
|
||||
|
||||
Args:
|
||||
conn (Connection): RW-подключение.
|
||||
dn (str): DN удаляемого объекта.
|
||||
do_write (bool): Если False — noop.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если сервер вернул ошибку удаления.
|
||||
"""
|
||||
if not do_write:
|
||||
return
|
||||
if not conn.delete(dn):
|
||||
raise RuntimeError(f"LDAP delete failed for {dn}: {conn.result}")
|
||||
|
||||
|
||||
def export_users_delete(*, cfg: SyncConfig, employees: Iterable[Employee]) -> int:
|
||||
"""Удаляет учётные записи пользователей в LDAP по их DN.
|
||||
|
||||
⚠️ Передайте конкретный набор `employees` (уволенные и т.п.).
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг (`dry_run`).
|
||||
employees (Iterable[Employee]): Кого удалять.
|
||||
|
||||
Returns:
|
||||
int: Количество успешно удалённых DN (включая dry-run).
|
||||
"""
|
||||
do_write = not cfg.dry_run
|
||||
deleted = 0
|
||||
|
||||
with _ldap() as conn:
|
||||
for emp in employees:
|
||||
state_dn = (
|
||||
LdapSyncState.objects.filter(model="employee", object_pk=str(emp.pk))
|
||||
.values_list("ldap_dn", flat=True)
|
||||
.first()
|
||||
)
|
||||
user_dn = (state_dn or "").strip()
|
||||
if not user_dn:
|
||||
logger.warning(
|
||||
"[WARN] Пропуск удаления: у сотрудника pk=%s пустой ldap_dn", emp.pk
|
||||
)
|
||||
continue
|
||||
if not (
|
||||
conn.search(user_dn, "(objectClass=*)", BASE, attributes=["dn"])
|
||||
and conn.entries
|
||||
):
|
||||
logger.warning("[WARN] DN не найден в LDAP, пропуск: %s", user_dn)
|
||||
continue
|
||||
_ldap_delete_dn(conn, user_dn, do_write=do_write)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
# ----------------------------- ОРКЕСТРАТОР: ПОЛНАЯ СИНХРОНИЗАЦИЯ ----------------------------- #
|
||||
|
||||
|
||||
def export_users(*, cfg: SyncConfig) -> tuple[int, int, int, int, int]:
|
||||
"""Полный экспорт пользователей из Django в LDAP (логины/UPN, MOVE, avatar, группы) + фиксация LdapSyncState.
|
||||
|
||||
Args:
|
||||
cfg (SyncConfig): Конфиг синхронизации.
|
||||
|
||||
Returns:
|
||||
tuple[int, int, int, int, int]: (logins_set, moved, avatars_set, groups_added, groups_removed).
|
||||
"""
|
||||
logins_set = export_users_create_attrs(cfg=cfg)
|
||||
moved, avatars_set = export_users_update_profile(cfg=cfg)
|
||||
groups_added, groups_removed = export_users_sync_groups(cfg=cfg)
|
||||
|
||||
do_write = not cfg.dry_run
|
||||
with _ldap() as conn, transaction.atomic():
|
||||
now = timezone.now()
|
||||
for emp in _employees_with_dn_qs().only("pk"):
|
||||
try:
|
||||
user_dn = _ensure_and_persist_user_dn(conn, emp, do_write=do_write)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[WARN] state.touch пропущен: %s", e)
|
||||
continue
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
)
|
||||
state.touch(ldap_dn=user_dn, last_django_modify_ts=now, sync_dir="django")
|
||||
with _ldap() as conn:
|
||||
from django.db import transaction
|
||||
|
||||
with transaction.atomic():
|
||||
now = timezone.now()
|
||||
for emp in _employees_with_dn_qs().only("pk"):
|
||||
try:
|
||||
user_dn = _ensure_and_persist_user_dn(
|
||||
conn, emp, do_write=do_write
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[WARN] state.touch пропущен: %s", e)
|
||||
continue
|
||||
state, _ = LdapSyncState.objects.get_or_create(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
)
|
||||
state.touch(
|
||||
ldap_dn=user_dn,
|
||||
last_django_modify_ts=now,
|
||||
sync_dir="django",
|
||||
)
|
||||
|
||||
return logins_set, moved, avatars_set, groups_added, groups_removed
|
||||
|
||||
264
backend/employees/ldap/tests/README.md
Normal file
264
backend/employees/ldap/tests/README.md
Normal file
@ -0,0 +1,264 @@
|
||||
# Тесты LDAP модуля
|
||||
|
||||
Комплексный набор тестов для проверки функциональности LDAP модуля после рефакторинга.
|
||||
|
||||
## Структура тестов
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Общие фикстуры и конфигурация pytest
|
||||
├── unit/ # Unit-тесты отдельных сервисов
|
||||
│ ├── test_user_service.py
|
||||
│ ├── test_department_service.py
|
||||
│ ├── test_group_service.py
|
||||
│ └── test_position_service.py
|
||||
├── integration/ # Интеграционные тесты
|
||||
│ └── test_integration.py
|
||||
└── fixtures/ # Тестовые данные и фикстуры
|
||||
```
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
### Все тесты
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/
|
||||
```
|
||||
|
||||
### Только unit-тесты
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/unit/
|
||||
```
|
||||
|
||||
### Только интеграционные тесты
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/integration/
|
||||
```
|
||||
|
||||
### Конкретный файл
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/unit/test_user_service.py
|
||||
```
|
||||
|
||||
### С покрытием кода
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/ --cov=employees.ldap --cov-report=html
|
||||
```
|
||||
|
||||
### В verbose режиме
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/ -v
|
||||
```
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### UserService (test_user_service.py)
|
||||
- ✅ Создание пользователей
|
||||
- ✅ Обновление базовых полей
|
||||
- ✅ Обновление пароля
|
||||
- ✅ Обновление отдела
|
||||
- ✅ Мягкое удаление (деактивация)
|
||||
- ✅ Жесткое удаление
|
||||
- ✅ Генерация уникальных логинов
|
||||
- ✅ Конвертация ID → DN
|
||||
- ✅ Перемещение между OU
|
||||
- ✅ Синхронизация из AD
|
||||
|
||||
### DepartmentService (test_department_service.py)
|
||||
- ✅ Создание отделов
|
||||
- ✅ Создание вложенных отделов
|
||||
- ✅ Создание с руководителем
|
||||
- ✅ Переименование отдела
|
||||
- ✅ Изменение руководителя
|
||||
- ✅ Удаление с перемещением сотрудников
|
||||
- ✅ Удаление без сотрудников
|
||||
- ✅ Добавление членов
|
||||
- ✅ Удаление членов
|
||||
- ✅ Назначение руководителя
|
||||
- ✅ Получение DN отдела
|
||||
- ✅ Поиск отдела по DN
|
||||
- ✅ Синхронизация группы отдела
|
||||
|
||||
### GroupService (test_group_service.py)
|
||||
- ✅ Создание групп
|
||||
- ✅ Создание с описанием
|
||||
- ✅ Удаление групп
|
||||
- ✅ Переименование групп
|
||||
- ✅ Изменение описания
|
||||
- ✅ Добавление членов
|
||||
- ✅ Удаление членов
|
||||
- ✅ Замена всех членов
|
||||
- ✅ Получение списка членов
|
||||
- ✅ Поиск DN по имени
|
||||
- ✅ Поиск групп с пользователем
|
||||
- ✅ Синхронизация каталога
|
||||
|
||||
### PositionService (test_position_service.py)
|
||||
- ✅ Синхронизация должностей
|
||||
- ✅ Синхронизация вложенности
|
||||
- ✅ Назначение должности
|
||||
- ✅ Снятие должности
|
||||
- ✅ Удаление группы должности
|
||||
- ✅ Получение базового DN
|
||||
- ✅ Создание базового OU
|
||||
- ✅ Создание/получение группы должности
|
||||
|
||||
### Интеграционные тесты (test_integration.py)
|
||||
- ✅ Полный жизненный цикл пользователя
|
||||
- ✅ Пользователь с отделом
|
||||
- ✅ Отдел с членами и руководителем
|
||||
- ✅ Реструктуризация отделов
|
||||
- ✅ Назначение должностей
|
||||
- ✅ Иерархия должностей
|
||||
- ✅ Массовый импорт пользователей
|
||||
- ✅ Реструктуризация организации
|
||||
|
||||
## Фикстуры (conftest.py)
|
||||
|
||||
### Mock LDAP соединения
|
||||
- `mock_ldap_connection` - Mock LDAP connection
|
||||
- `mock_ldap_context` - Mock контекстного менеджера _ldap()
|
||||
|
||||
### Django модели
|
||||
- `sample_employee` - Тестовый сотрудник
|
||||
- `sample_department` - Тестовый отдел
|
||||
- `sample_position` - Тестовая должность
|
||||
- `sample_django_group` - Тестовая Django группа
|
||||
- `sample_ldap_sync_state` - Тестовая запись синхронизации
|
||||
|
||||
### DTO объекты
|
||||
- `sample_user_dto` - Тестовый DirectoryUserDTO
|
||||
- `sample_department_dto` - Тестовый DirectoryDepartmentDTO
|
||||
|
||||
### Mock репозитории
|
||||
- `mock_ldap_repository` - Mock LdapRepository
|
||||
- `mock_employee_repository` - Mock EmployeeRepository
|
||||
- `mock_sync_state_repository` - Mock SyncStateRepository
|
||||
|
||||
### Mock сервисы
|
||||
- `mock_user_service` - Mock UserService
|
||||
- `mock_group_service` - Mock GroupService
|
||||
- `mock_department_service` - Mock DepartmentService
|
||||
- `mock_position_service` - Mock PositionService
|
||||
|
||||
### LDAP объекты
|
||||
- `mock_ldap_entry` - Mock LDAP entry с атрибутами
|
||||
|
||||
### Настройки
|
||||
- `ldap_test_settings` - Тестовые LDAP настройки
|
||||
|
||||
## Стратегия тестирования
|
||||
|
||||
### Unit-тесты
|
||||
- **Изоляция**: Каждый сервис тестируется изолированно
|
||||
- **Mocking**: Все зависимости (LDAP, ORM, другие сервисы) мокируются
|
||||
- **Фокус**: Проверка логики конкретного сервиса
|
||||
- **Скорость**: Быстрые тесты без внешних зависимостей
|
||||
|
||||
### Интеграционные тесты
|
||||
- **Взаимодействие**: Тестируется работа нескольких сервисов вместе
|
||||
- **Сценарии**: Полные бизнес-процессы (создание → изменение → удаление)
|
||||
- **Mock LDAP**: LDAP соединение мокируется, Django ORM работает с тестовой БД
|
||||
- **Реальность**: Приближены к реальным сценариям использования
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
### Unit-тест для нового метода
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
def test_new_method(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест нового метода."""
|
||||
# Arrange
|
||||
service = YourService(mock_ldap_repository)
|
||||
|
||||
# Act
|
||||
result = service.new_method()
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert mock_ldap_connection.some_operation.called
|
||||
```
|
||||
|
||||
### Интеграционный тест
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
def test_new_workflow(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services
|
||||
):
|
||||
"""Тест нового workflow."""
|
||||
service1 = all_services['service1']
|
||||
service2 = all_services['service2']
|
||||
|
||||
# Act: выполняем последовательность операций
|
||||
service1.operation1()
|
||||
service2.operation2()
|
||||
|
||||
# Assert: проверяем результат
|
||||
assert mock_ldap_connection.add.call_count == 2
|
||||
```
|
||||
|
||||
## CI/CD интеграция
|
||||
|
||||
Тесты можно интегрировать в CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/tests.yml
|
||||
- name: Run LDAP tests
|
||||
run: |
|
||||
pytest backend/employees/ldap/tests/ \
|
||||
--cov=employees.ldap \
|
||||
--cov-report=xml \
|
||||
--junitxml=junit.xml
|
||||
```
|
||||
|
||||
## Покрытие кода
|
||||
|
||||
Целевое покрытие: **>80%**
|
||||
|
||||
Проверка текущего покрытия:
|
||||
```bash
|
||||
pytest backend/employees/ldap/tests/ \
|
||||
--cov=employees.ldap \
|
||||
--cov-report=term-missing
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Тесты не находят модули
|
||||
```bash
|
||||
# Убедитесь, что PYTHONPATH настроен правильно
|
||||
export PYTHONPATH="${PYTHONPATH}:/path/to/backend"
|
||||
```
|
||||
|
||||
### Ошибки базы данных
|
||||
```bash
|
||||
# Используйте тестовую БД
|
||||
pytest --ds=eusrr_backend.settings_test
|
||||
```
|
||||
|
||||
### Mock не работает
|
||||
```python
|
||||
# Проверьте правильность пути в patch
|
||||
# Неправильно:
|
||||
with patch('ldap.Repository'):
|
||||
...
|
||||
|
||||
# Правильно:
|
||||
with patch('employees.ldap.infrastructure.repositories.ldap_repository.LdapRepository'):
|
||||
...
|
||||
```
|
||||
|
||||
## Дополнительные ресурсы
|
||||
|
||||
- [pytest документация](https://docs.pytest.org/)
|
||||
- [unittest.mock документация](https://docs.python.org/3/library/unittest.mock.html)
|
||||
- [pytest-django документация](https://pytest-django.readthedocs.io/)
|
||||
- [Архитектура LDAP модуля](../ARCHITECTURE.md)
|
||||
- [Руководство по миграции](../MIGRATION_GUIDE.md)
|
||||
3
backend/employees/ldap/tests/__init__.py
Normal file
3
backend/employees/ldap/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Тесты LDAP модуля.
|
||||
"""
|
||||
295
backend/employees/ldap/tests/conftest.py
Normal file
295
backend/employees/ldap/tests/conftest.py
Normal file
@ -0,0 +1,295 @@
|
||||
"""
|
||||
Pytest конфигурация и фикстуры для тестирования LDAP модуля.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from django.contrib.auth.models import Group as DjangoGroup
|
||||
|
||||
from employees.models import Employee, Department, Position, LdapSyncState
|
||||
from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
|
||||
|
||||
|
||||
# ==================== Mock LDAP Connection ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ldap_connection():
|
||||
"""Mock LDAP соединения для изоляции от реального AD."""
|
||||
conn = Mock()
|
||||
conn.result = {'description': 'success'}
|
||||
conn.entries = []
|
||||
|
||||
# Mock базовых операций
|
||||
conn.add = Mock(return_value=True)
|
||||
conn.delete = Mock(return_value=True)
|
||||
conn.modify = Mock(return_value=True)
|
||||
conn.modify_dn = Mock(return_value=True)
|
||||
conn.search = Mock(return_value=True)
|
||||
|
||||
# Mock extend для паролей
|
||||
conn.extend = Mock()
|
||||
conn.extend.microsoft = Mock()
|
||||
conn.extend.microsoft.modify_password = Mock(return_value=True)
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ldap_context(mock_ldap_connection):
|
||||
"""Mock контекстного менеджера _ldap()."""
|
||||
with patch('employees.ldap.infrastructure.connections._ldap') as mock:
|
||||
mock.return_value.__enter__.return_value = mock_ldap_connection
|
||||
mock.return_value.__exit__.return_value = None
|
||||
yield mock
|
||||
|
||||
|
||||
# ==================== Django Models Fixtures ====================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_employee(db):
|
||||
"""Создает тестового сотрудника."""
|
||||
return Employee.objects.create(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
email="ivanov@example.com",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_department(db):
|
||||
"""Создает тестовый отдел."""
|
||||
return Department.objects.create(
|
||||
name="IT Отдел",
|
||||
description="IT Department"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_position(db):
|
||||
"""Создает тестовую должность."""
|
||||
return Position.objects.create(
|
||||
name="Разработчик",
|
||||
description="Backend разработчик"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_django_group(db):
|
||||
"""Создает тестовую Django группу."""
|
||||
return DjangoGroup.objects.create(name="Developers")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_ldap_sync_state(db, sample_employee):
|
||||
"""Создает тестовую запись синхронизации."""
|
||||
return LdapSyncState.objects.create(
|
||||
model='employee',
|
||||
object_pk=str(sample_employee.id),
|
||||
ldap_dn='CN=Иван Иванов,OU=Users,DC=example,DC=com'
|
||||
)
|
||||
|
||||
|
||||
# ==================== DTO Fixtures ====================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_dto():
|
||||
"""Создает тестовый DTO для пользователя."""
|
||||
return DirectoryUserDTO(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
email="ivanov@example.com",
|
||||
phone_e164="+79001234567",
|
||||
department_dn="OU=IT,DC=example,DC=com",
|
||||
group_cns=["Developers", "Users"],
|
||||
initial_password="Test123456!",
|
||||
avatar_bytes=None,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_department_dto():
|
||||
"""Создает тестовый DTO для отдела."""
|
||||
return DirectoryDepartmentDTO(
|
||||
name="IT Отдел",
|
||||
description="IT Department",
|
||||
head=None
|
||||
)
|
||||
|
||||
|
||||
# ==================== Mock Repositories ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ldap_repository():
|
||||
"""Mock LdapRepository."""
|
||||
repo = Mock()
|
||||
repo.read_attrs = Mock(return_value={'cn': ['Test User']})
|
||||
repo.is_taken = Mock(return_value=False)
|
||||
repo.modify_attrs = Mock(return_value=True)
|
||||
repo.ensure_container_exists = Mock(return_value=True)
|
||||
repo.modify_or_ignore = Mock(return_value=True)
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_employee_repository():
|
||||
"""Mock EmployeeRepository."""
|
||||
repo = Mock()
|
||||
repo.load_users_index = Mock(return_value=({}, {}))
|
||||
repo.find_user_for_dto = Mock(return_value=None)
|
||||
repo.bind_user_department = Mock(return_value=True)
|
||||
repo.cleanup_absent_users = Mock(return_value=0)
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sync_state_repository():
|
||||
"""Mock SyncStateRepository."""
|
||||
repo = Mock()
|
||||
repo.get_or_create = Mock()
|
||||
repo.touch = Mock()
|
||||
repo.get_employees_with_dn = Mock(return_value=Employee.objects.none())
|
||||
repo.delete_for_employee = Mock()
|
||||
return repo
|
||||
|
||||
|
||||
# ==================== Mock Services ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_service():
|
||||
"""Mock UserService для тестирования других сервисов."""
|
||||
service = Mock()
|
||||
service.create_user = Mock()
|
||||
service.update_user = Mock()
|
||||
service.delete_user = Mock()
|
||||
service._get_employee_dn = Mock(
|
||||
return_value='CN=Test User,OU=Users,DC=example,DC=com'
|
||||
)
|
||||
service._move_user_to_base = Mock(
|
||||
return_value='CN=Test User,OU=NewBase,DC=example,DC=com'
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_group_service():
|
||||
"""Mock GroupService для тестирования других сервисов."""
|
||||
service = Mock()
|
||||
dn = 'CN=Test Group,OU=Groups,DC=example,DC=com'
|
||||
service.create = Mock(return_value=dn)
|
||||
service.delete = Mock()
|
||||
service.rename = Mock()
|
||||
service.set_description = Mock()
|
||||
service.add_members = Mock()
|
||||
service.remove_members = Mock()
|
||||
service.replace_members = Mock()
|
||||
service.list_members = Mock(return_value=[])
|
||||
service.find_dn = Mock(return_value=dn)
|
||||
service.groups_with_member = Mock(return_value=set())
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_department_service():
|
||||
"""Mock DepartmentService для тестирования."""
|
||||
service = Mock()
|
||||
service.create_department = Mock()
|
||||
service.update_department = Mock()
|
||||
service.delete_department = Mock()
|
||||
service.add_member = Mock()
|
||||
service.remove_member = Mock()
|
||||
service.set_head = Mock()
|
||||
service._get_department_dn = Mock(
|
||||
return_value='OU=Test Department,DC=example,DC=com'
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_position_service():
|
||||
"""Mock PositionService для тестирования."""
|
||||
service = Mock()
|
||||
service.reconcile_position = Mock()
|
||||
service.assign_position = Mock()
|
||||
service.unassign_position = Mock()
|
||||
service.delete_position_group = Mock()
|
||||
return service
|
||||
|
||||
|
||||
# ==================== LDAP Entry Mocks ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ldap_entry():
|
||||
"""Mock LDAP entry объекта."""
|
||||
entry = Mock()
|
||||
entry.entry_dn = 'CN=Test User,OU=Users,DC=example,DC=com'
|
||||
entry.cn = Mock()
|
||||
entry.cn.value = 'Test User'
|
||||
entry.mail = Mock()
|
||||
entry.mail.value = 'test@example.com'
|
||||
entry.objectGUID = Mock()
|
||||
# Mock GUID (16 bytes)
|
||||
guid_bytes = (
|
||||
b'\x01\x02\x03\x04\x05\x06\x07\x08'
|
||||
b'\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
|
||||
)
|
||||
entry.objectGUID.value = guid_bytes
|
||||
return entry
|
||||
|
||||
|
||||
# ==================== Settings Overrides ====================
|
||||
|
||||
@pytest.fixture
|
||||
def ldap_test_settings(settings):
|
||||
"""Тестовые настройки LDAP."""
|
||||
settings.LDAP_HOST = 'ldaps://test.example.com:636'
|
||||
settings.LDAP_BASE_DN = 'DC=example,DC=com'
|
||||
settings.LDAP_BIND_DN = 'CN=TestUser,DC=example,DC=com'
|
||||
settings.LDAP_BIND_PASSWORD = 'test_password'
|
||||
settings.LDAP_GROUPS_BASE = 'OU=Groups,DC=example,DC=com'
|
||||
settings.LDAP_POSITIONS_BASE = 'OU=Positions,DC=example,DC=com'
|
||||
settings.LDAP_UPN_SUFFIX = '@example.com'
|
||||
return settings
|
||||
|
||||
|
||||
# ==================== Helper Functions ====================
|
||||
|
||||
def create_mock_ldap_entry(dn, attributes=None):
|
||||
"""
|
||||
Создает mock LDAP entry с заданными атрибутами.
|
||||
|
||||
Args:
|
||||
dn: Distinguished Name
|
||||
attributes: Словарь атрибутов {attr_name: value}
|
||||
|
||||
Returns:
|
||||
Mock объект LDAP entry
|
||||
"""
|
||||
entry = Mock()
|
||||
entry.entry_dn = dn
|
||||
|
||||
if attributes:
|
||||
for attr_name, attr_value in attributes.items():
|
||||
attr_mock = Mock()
|
||||
attr_mock.value = attr_value
|
||||
setattr(entry, attr_name, attr_mock)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def assert_ldap_operation_called(mock_conn, operation, dn=None):
|
||||
"""
|
||||
Проверяет, что LDAP операция была вызвана.
|
||||
|
||||
Args:
|
||||
mock_conn: Mock LDAP соединения
|
||||
operation: Название операции ('add', 'delete', 'modify', etc.)
|
||||
dn: Ожидаемый DN (опционально)
|
||||
"""
|
||||
op_mock = getattr(mock_conn, operation)
|
||||
assert op_mock.called, f"LDAP операция '{operation}' не была вызвана"
|
||||
|
||||
if dn and op_mock.call_args:
|
||||
called_dn = op_mock.call_args[0][0]
|
||||
assert called_dn == dn, f"Ожидался DN '{dn}', получен '{called_dn}'"
|
||||
3
backend/employees/ldap/tests/fixtures/__init__.py
vendored
Normal file
3
backend/employees/ldap/tests/fixtures/__init__.py
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Тестовые данные и фикстуры.
|
||||
"""
|
||||
309
backend/employees/ldap/tests/fixtures/sample_data.py
vendored
Normal file
309
backend/employees/ldap/tests/fixtures/sample_data.py
vendored
Normal file
@ -0,0 +1,309 @@
|
||||
"""
|
||||
Примеры тестовых данных и фикстур для LDAP тестов.
|
||||
|
||||
Этот модуль содержит готовые тестовые данные, которые можно использовать
|
||||
в тестах вместо создания их каждый раз заново.
|
||||
"""
|
||||
|
||||
from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
|
||||
|
||||
|
||||
# ==================== Тестовые пользователи ====================
|
||||
|
||||
SAMPLE_USERS = [
|
||||
DirectoryUserDTO(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
patronymic="Петрович",
|
||||
email="ivanov@example.com",
|
||||
phone="+79001234567",
|
||||
password="TestPass123!",
|
||||
avatar=None,
|
||||
groups=["Developers", "Users"]
|
||||
),
|
||||
DirectoryUserDTO(
|
||||
first_name="Мария",
|
||||
last_name="Петрова",
|
||||
patronymic="Сергеевна",
|
||||
email="petrova@example.com",
|
||||
phone="+79007654321",
|
||||
password="TestPass456!",
|
||||
avatar=None,
|
||||
groups=["HR", "Users"]
|
||||
),
|
||||
DirectoryUserDTO(
|
||||
first_name="Алексей",
|
||||
last_name="Сидоров",
|
||||
patronymic="Александрович",
|
||||
email="sidorov@example.com",
|
||||
phone="+79009876543",
|
||||
password="TestPass789!",
|
||||
avatar=None,
|
||||
groups=["Management", "Users"]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ==================== Тестовые отделы ====================
|
||||
|
||||
SAMPLE_DEPARTMENTS = [
|
||||
DirectoryDepartmentDTO(
|
||||
name="IT Отдел",
|
||||
parent=None,
|
||||
head=None
|
||||
),
|
||||
DirectoryDepartmentDTO(
|
||||
name="HR Отдел",
|
||||
parent=None,
|
||||
head=None
|
||||
),
|
||||
DirectoryDepartmentDTO(
|
||||
name="Отдел разработки",
|
||||
parent=None, # Будет установлен в тестах как IT Отдел
|
||||
head=None
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ==================== Тестовые LDAP DN ====================
|
||||
|
||||
SAMPLE_DNS = {
|
||||
'users': [
|
||||
'CN=Иван Иванов,OU=Users,DC=example,DC=com',
|
||||
'CN=Мария Петрова,OU=Users,DC=example,DC=com',
|
||||
'CN=Алексей Сидоров,OU=Users,DC=example,DC=com',
|
||||
],
|
||||
'departments': [
|
||||
'OU=IT,DC=example,DC=com',
|
||||
'OU=HR,DC=example,DC=com',
|
||||
'OU=Development,OU=IT,DC=example,DC=com',
|
||||
],
|
||||
'groups': [
|
||||
'CN=Developers,OU=Groups,DC=example,DC=com',
|
||||
'CN=HR,OU=Groups,DC=example,DC=com',
|
||||
'CN=Management,OU=Groups,DC=example,DC=com',
|
||||
'CN=Users,OU=Groups,DC=example,DC=com',
|
||||
],
|
||||
'positions': [
|
||||
'CN=POS_1,OU=Positions,DC=example,DC=com',
|
||||
'CN=POS_2,OU=Positions,DC=example,DC=com',
|
||||
'CN=POS_3,OU=Positions,DC=example,DC=com',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ==================== Тестовые LDAP атрибуты ====================
|
||||
|
||||
SAMPLE_LDAP_ATTRIBUTES = {
|
||||
'user': {
|
||||
'cn': 'Иван Иванов',
|
||||
'sAMAccountName': 'ivanov',
|
||||
'userPrincipalName': 'ivanov@example.com',
|
||||
'givenName': 'Иван',
|
||||
'sn': 'Иванов',
|
||||
'displayName': 'Иван Петрович Иванов',
|
||||
'mail': 'ivanov@example.com',
|
||||
'telephoneNumber': '+79001234567',
|
||||
'userAccountControl': 512, # Normal account
|
||||
'objectClass': ['top', 'person', 'organizationalPerson', 'user'],
|
||||
},
|
||||
'group': {
|
||||
'cn': 'Developers',
|
||||
'sAMAccountName': 'Developers',
|
||||
'description': 'Development team',
|
||||
'member': [
|
||||
'CN=Иван Иванов,OU=Users,DC=example,DC=com',
|
||||
'CN=Мария Петрова,OU=Users,DC=example,DC=com',
|
||||
],
|
||||
'objectClass': ['top', 'group'],
|
||||
'groupType': -2147483646, # Global security group
|
||||
},
|
||||
'ou': {
|
||||
'ou': 'IT',
|
||||
'name': 'IT',
|
||||
'description': 'IT Department',
|
||||
'objectClass': ['top', 'organizationalUnit'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==================== Тестовые LDAP фильтры ====================
|
||||
|
||||
SAMPLE_LDAP_FILTERS = {
|
||||
'all_users': '(&(objectClass=user)(objectCategory=person))',
|
||||
'active_users': (
|
||||
'(&(objectClass=user)(objectCategory=person)'
|
||||
'(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
|
||||
),
|
||||
'all_groups': '(objectClass=group)',
|
||||
'group_by_name': '(&(objectClass=group)(cn={name}))',
|
||||
'user_by_login': (
|
||||
'(&(objectClass=user)(objectCategory=person)'
|
||||
'(sAMAccountName={login}))'
|
||||
),
|
||||
'groups_with_member': (
|
||||
'(&(objectClass=group)(member={dn}))'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ==================== Тестовые настройки ====================
|
||||
|
||||
TEST_LDAP_SETTINGS = {
|
||||
'LDAP_HOST': 'ldaps://test.example.com:636',
|
||||
'LDAP_BASE_DN': 'DC=example,DC=com',
|
||||
'LDAP_BIND_DN': 'CN=ServiceAccount,DC=example,DC=com',
|
||||
'LDAP_BIND_PASSWORD': 'TestPassword123!',
|
||||
'LDAP_USERS_BASE': 'OU=Users,DC=example,DC=com',
|
||||
'LDAP_GROUPS_BASE': 'OU=Groups,DC=example,DC=com',
|
||||
'LDAP_POSITIONS_BASE': 'OU=Positions,DC=example,DC=com',
|
||||
'LDAP_UPN_SUFFIX': '@example.com',
|
||||
'LDAP_CONNECT_TIMEOUT': 5,
|
||||
'LDAP_RECEIVE_TIMEOUT': 10,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Вспомогательные функции ====================
|
||||
|
||||
def create_test_user_dto(
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Создает тестовый DirectoryUserDTO с настраиваемыми параметрами.
|
||||
|
||||
Args:
|
||||
first_name: Имя
|
||||
last_name: Фамилия
|
||||
email: Email (по умолчанию генерируется из имени)
|
||||
**kwargs: Дополнительные параметры
|
||||
|
||||
Returns:
|
||||
DirectoryUserDTO
|
||||
"""
|
||||
if email is None:
|
||||
email = f"{last_name.lower()}@example.com"
|
||||
|
||||
return DirectoryUserDTO(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
password=kwargs.get('password', 'TestPass123!'),
|
||||
patronymic=kwargs.get('patronymic', 'Тестович'),
|
||||
phone=kwargs.get('phone', '+79001234567'),
|
||||
avatar=kwargs.get('avatar'),
|
||||
groups=kwargs.get('groups', ['Users'])
|
||||
)
|
||||
|
||||
|
||||
def create_test_department_dto(
|
||||
name="Test Department",
|
||||
short_name=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Создает тестовый DirectoryDepartmentDTO с настраиваемыми параметрами.
|
||||
|
||||
Args:
|
||||
name: Название отдела
|
||||
short_name: Короткое название (по умолчанию из name)
|
||||
**kwargs: Дополнительные параметры
|
||||
|
||||
Returns:
|
||||
DirectoryDepartmentDTO
|
||||
"""
|
||||
if short_name is None:
|
||||
short_name = ''.join([w[0] for w in name.split()])
|
||||
|
||||
return DirectoryDepartmentDTO(
|
||||
name=name,
|
||||
parent=kwargs.get('parent'),
|
||||
head=kwargs.get('head')
|
||||
)
|
||||
|
||||
|
||||
def create_test_ldap_entry(dn, attributes=None):
|
||||
"""
|
||||
Создает mock LDAP entry с заданными атрибутами.
|
||||
|
||||
Args:
|
||||
dn: Distinguished Name
|
||||
attributes: Словарь атрибутов
|
||||
|
||||
Returns:
|
||||
Mock объект LDAP entry
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
entry = Mock()
|
||||
entry.entry_dn = dn
|
||||
|
||||
if attributes:
|
||||
for attr_name, attr_value in attributes.items():
|
||||
attr_mock = Mock()
|
||||
attr_mock.value = attr_value
|
||||
setattr(entry, attr_name, attr_mock)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def generate_test_dns(count=10, ou='Users'):
|
||||
"""
|
||||
Генерирует список тестовых DN.
|
||||
|
||||
Args:
|
||||
count: Количество DN
|
||||
ou: Organizational Unit
|
||||
|
||||
Returns:
|
||||
List[str]: Список DN
|
||||
"""
|
||||
return [
|
||||
f'CN=User{i},OU={ou},DC=example,DC=com'
|
||||
for i in range(1, count + 1)
|
||||
]
|
||||
|
||||
|
||||
def get_sample_user_attributes(index=0):
|
||||
"""
|
||||
Возвращает атрибуты тестового пользователя.
|
||||
|
||||
Args:
|
||||
index: Индекс пользователя (0-2)
|
||||
|
||||
Returns:
|
||||
dict: Словарь LDAP атрибутов
|
||||
"""
|
||||
users = [
|
||||
{
|
||||
'cn': 'Иван Иванов',
|
||||
'sAMAccountName': 'ivanov',
|
||||
'userPrincipalName': 'ivanov@example.com',
|
||||
'givenName': 'Иван',
|
||||
'sn': 'Иванов',
|
||||
'mail': 'ivanov@example.com',
|
||||
'telephoneNumber': '+79001234567',
|
||||
},
|
||||
{
|
||||
'cn': 'Мария Петрова',
|
||||
'sAMAccountName': 'petrova',
|
||||
'userPrincipalName': 'petrova@example.com',
|
||||
'givenName': 'Мария',
|
||||
'sn': 'Петрова',
|
||||
'mail': 'petrova@example.com',
|
||||
'telephoneNumber': '+79007654321',
|
||||
},
|
||||
{
|
||||
'cn': 'Алексей Сидоров',
|
||||
'sAMAccountName': 'sidorov',
|
||||
'userPrincipalName': 'sidorov@example.com',
|
||||
'givenName': 'Алексей',
|
||||
'sn': 'Сидоров',
|
||||
'mail': 'sidorov@example.com',
|
||||
'telephoneNumber': '+79009876543',
|
||||
},
|
||||
]
|
||||
|
||||
return users[index % len(users)]
|
||||
3
backend/employees/ldap/tests/integration/__init__.py
Normal file
3
backend/employees/ldap/tests/integration/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Интеграционные тесты LDAP модуля.
|
||||
"""
|
||||
428
backend/employees/ldap/tests/integration/test_integration.py
Normal file
428
backend/employees/ldap/tests/integration/test_integration.py
Normal file
@ -0,0 +1,428 @@
|
||||
"""
|
||||
Интеграционные тесты для LDAP модуля.
|
||||
|
||||
Эти тесты проверяют взаимодействие между сервисами
|
||||
и полные сценарии работы с пользователями, отделами и должностями.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from employees.models import Employee, Department, Position
|
||||
from employees.ldap.services.user_service import UserService
|
||||
from employees.ldap.services.department_service import DepartmentService
|
||||
from employees.ldap.services.group_service import GroupService
|
||||
from employees.ldap.services.position_service import PositionService
|
||||
from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_services(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
):
|
||||
"""Создает все сервисы для интеграционных тестов."""
|
||||
group_service = GroupService(mock_ldap_repository)
|
||||
user_service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
department_service = DepartmentService(
|
||||
group_service,
|
||||
user_service
|
||||
)
|
||||
position_service = PositionService(group_service, user_service)
|
||||
|
||||
return {
|
||||
'user': user_service,
|
||||
'department': department_service,
|
||||
'group': group_service,
|
||||
'position': position_service
|
||||
}
|
||||
|
||||
|
||||
class TestUserLifecycle:
|
||||
"""Тесты полного жизненного цикла пользователя."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_full_user_lifecycle(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services,
|
||||
sample_user_dto
|
||||
):
|
||||
"""
|
||||
Тест полного цикла: создание → обновление → деактивация → удаление.
|
||||
"""
|
||||
user_service = all_services['user']
|
||||
|
||||
# 1. Создание пользователя
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = 1
|
||||
mock_employee.first_name = "Иван"
|
||||
mock_employee.last_name = "Иванов"
|
||||
mock_employee.is_active = True
|
||||
mock_create.return_value = mock_employee
|
||||
|
||||
employee = user_service.create_user(sample_user_dto)
|
||||
assert employee is not None
|
||||
assert mock_ldap_connection.add.called
|
||||
|
||||
# 2. Обновление пользователя
|
||||
sample_user_dto.email = "new_email@example.com"
|
||||
user_service.update_user(employee, sample_user_dto)
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
# 3. Мягкое удаление (деактивация)
|
||||
user_service.delete_user(employee, soft=True)
|
||||
assert not employee.is_active
|
||||
|
||||
# 4. Жесткое удаление
|
||||
mock_ldap_connection.reset_mock()
|
||||
user_service.delete_user(employee, soft=False)
|
||||
assert mock_ldap_connection.delete.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_with_department(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services,
|
||||
sample_user_dto,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест создания пользователя с привязкой к отделу."""
|
||||
user_service = all_services['user']
|
||||
department_service = all_services['department']
|
||||
|
||||
# 1. Создаем отдел
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
department_service.create_department(sample_department_dto)
|
||||
|
||||
# 2. Создаем пользователя в отделе
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = 1
|
||||
mock_create.return_value = mock_employee
|
||||
|
||||
employee = user_service.create_user(sample_user_dto)
|
||||
|
||||
# 3. Добавляем в отдел
|
||||
with patch('employees.models.Department.objects.first') as mock_dept:
|
||||
mock_dept.return_value = Mock(spec=Department)
|
||||
mock_dept.return_value.id = 1
|
||||
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
department_service.add_member(
|
||||
mock_dept.return_value,
|
||||
employee
|
||||
)
|
||||
|
||||
# Assert: проверяем вызовы LDAP операций
|
||||
assert mock_ldap_connection.add.call_count >= 2
|
||||
|
||||
|
||||
class TestDepartmentWorkflow:
|
||||
"""Тесты рабочих процессов с отделами."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_with_members(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services,
|
||||
sample_department_dto,
|
||||
sample_user_dto
|
||||
):
|
||||
"""
|
||||
Тест создания отдела с сотрудниками.
|
||||
Создание отдела → добавление сотрудников → назначение руководителя.
|
||||
"""
|
||||
department_service = all_services['department']
|
||||
user_service = all_services['user']
|
||||
|
||||
# 1. Создаем отдел
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
department_service.create_department(sample_department_dto)
|
||||
|
||||
# 2. Создаем сотрудников
|
||||
employees = []
|
||||
for i in range(3):
|
||||
with patch(
|
||||
'employees.models.Employee.objects.create'
|
||||
) as mock_create:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = i + 1
|
||||
mock_create.return_value = mock_employee
|
||||
|
||||
employee = user_service.create_user(sample_user_dto)
|
||||
employees.append(employee)
|
||||
|
||||
# 3. Добавляем сотрудников в отдел
|
||||
with patch('employees.models.Department.objects.first') as mock_dept:
|
||||
department = Mock(spec=Department)
|
||||
department.id = 1
|
||||
mock_dept.return_value = department
|
||||
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
for employee in employees:
|
||||
department_service.add_member(department, employee)
|
||||
|
||||
# 4. Назначаем руководителя
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
department_service.set_head(department, employees[0])
|
||||
|
||||
# Assert: множественные LDAP операции
|
||||
assert mock_ldap_connection.add.call_count >= 4
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_restructure(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services,
|
||||
sample_department
|
||||
):
|
||||
"""Тест реструктуризации: перемещение сотрудников между отделами."""
|
||||
department_service = all_services['department']
|
||||
|
||||
# Создаем 2 отдела
|
||||
dept1 = sample_department
|
||||
dept2 = Department.objects.create(
|
||||
name="HR Department",
|
||||
short_name="HR"
|
||||
)
|
||||
|
||||
# Создаем сотрудника в первом отделе
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
employee = Mock(spec=Employee)
|
||||
employee.id = 1
|
||||
employee.department = dept1
|
||||
mock_create.return_value = employee
|
||||
|
||||
# Перемещаем во второй отдел
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
side_effect=[
|
||||
'OU=IT,DC=example,DC=com',
|
||||
'OU=HR,DC=example,DC=com'
|
||||
]
|
||||
):
|
||||
department_service.remove_member(dept1, employee)
|
||||
department_service.add_member(dept2, employee)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
|
||||
class TestPositionAssignmentFlow:
|
||||
"""Тесты назначения должностей."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_position_assignment_workflow(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services,
|
||||
sample_position,
|
||||
sample_user_dto
|
||||
):
|
||||
"""
|
||||
Тест: создание пользователя → назначение должности → синхронизация.
|
||||
"""
|
||||
user_service = all_services['user']
|
||||
position_service = all_services['position']
|
||||
|
||||
# 1. Создаем пользователя
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
employee = Mock(spec=Employee)
|
||||
employee.id = 1
|
||||
mock_create.return_value = employee
|
||||
|
||||
employee = user_service.create_user(sample_user_dto)
|
||||
|
||||
# 2. Назначаем должность
|
||||
pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
|
||||
|
||||
with patch.object(
|
||||
position_service,
|
||||
'_ensure_position_group',
|
||||
return_value=pos_dn
|
||||
):
|
||||
position_service.assign_position(sample_position, employee)
|
||||
|
||||
# 3. Синхронизируем должность
|
||||
with patch.object(Employee.objects, 'filter') as mock_filter:
|
||||
mock_filter.return_value = [employee]
|
||||
|
||||
with patch.object(
|
||||
position_service,
|
||||
'_ensure_position_group',
|
||||
return_value=pos_dn
|
||||
):
|
||||
position_service.reconcile_position(sample_position)
|
||||
|
||||
# Assert: проверяем операции с группами
|
||||
group_service = all_services['group']
|
||||
assert group_service.add_members.call_count >= 1
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_position_hierarchy(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services
|
||||
):
|
||||
"""Тест иерархии должностей (должность в должности)."""
|
||||
position_service = all_services['position']
|
||||
|
||||
# Создаем родительскую и дочернюю должности
|
||||
parent_pos = Position.objects.create(
|
||||
name="Senior Developer",
|
||||
description="Senior position"
|
||||
)
|
||||
child_pos = Position.objects.create(
|
||||
name="Middle Developer",
|
||||
description="Middle position",
|
||||
parent=parent_pos
|
||||
)
|
||||
|
||||
parent_dn = f'CN=POS_{parent_pos.id},OU=Positions,DC=example,DC=com'
|
||||
child_dn = f'CN=POS_{child_pos.id},OU=Positions,DC=example,DC=com'
|
||||
|
||||
with patch.object(
|
||||
position_service,
|
||||
'_ensure_position_group',
|
||||
side_effect=[child_dn, parent_dn]
|
||||
):
|
||||
with patch.object(Employee.objects, 'filter') as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
# Синхронизируем дочернюю должность
|
||||
position_service.reconcile_position(child_pos)
|
||||
|
||||
# Assert: дочерняя группа должна быть добавлена в родительскую
|
||||
group_service = all_services['group']
|
||||
assert group_service.add_members.called
|
||||
|
||||
|
||||
class TestComplexScenarios:
|
||||
"""Тесты сложных сценариев."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bulk_user_import(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services
|
||||
):
|
||||
"""Тест массового импорта пользователей."""
|
||||
user_service = all_services['user']
|
||||
|
||||
# Подготавливаем данные для 10 пользователей
|
||||
users_data = []
|
||||
for i in range(10):
|
||||
dto = DirectoryUserDTO(
|
||||
first_name=f"User{i}",
|
||||
last_name=f"Last{i}",
|
||||
email=f"user{i}@example.com",
|
||||
password="Test123456!"
|
||||
)
|
||||
users_data.append(dto)
|
||||
|
||||
# Создаем пользователей
|
||||
created_count = 0
|
||||
for dto in users_data:
|
||||
with patch('employees.models.Employee.objects.create') as mock:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = created_count + 1
|
||||
mock.return_value = mock_employee
|
||||
|
||||
employee = user_service.create_user(dto)
|
||||
if employee:
|
||||
created_count += 1
|
||||
|
||||
# Assert
|
||||
assert created_count == 10
|
||||
assert mock_ldap_connection.add.call_count >= 10
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_organization_restructure(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
all_services
|
||||
):
|
||||
"""Тест полной реструктуризации организации."""
|
||||
department_service = all_services['department']
|
||||
|
||||
# Создаем структуру отделов
|
||||
root_dept = Department.objects.create(
|
||||
name="Company",
|
||||
short_name="ROOT"
|
||||
)
|
||||
|
||||
it_dept = Department.objects.create(
|
||||
name="IT Department",
|
||||
short_name="IT",
|
||||
parent=root_dept
|
||||
)
|
||||
|
||||
hr_dept = Department.objects.create(
|
||||
name="HR Department",
|
||||
short_name="HR",
|
||||
parent=root_dept
|
||||
)
|
||||
|
||||
# Mock DN генерацию
|
||||
def get_dept_dn(dept):
|
||||
if dept == root_dept:
|
||||
return 'OU=ROOT,DC=example,DC=com'
|
||||
elif dept == it_dept:
|
||||
return 'OU=IT,OU=ROOT,DC=example,DC=com'
|
||||
elif dept == hr_dept:
|
||||
return 'OU=HR,OU=ROOT,DC=example,DC=com'
|
||||
return 'DC=example,DC=com'
|
||||
|
||||
with patch.object(
|
||||
department_service,
|
||||
'_get_department_dn',
|
||||
side_effect=get_dept_dn
|
||||
):
|
||||
# Создаем отделы в AD
|
||||
dto = DirectoryDepartmentDTO(name="Company", parent=None)
|
||||
department_service.create_department(dto)
|
||||
|
||||
dto = DirectoryDepartmentDTO(name="IT", parent=root_dept)
|
||||
department_service.create_department(dto)
|
||||
|
||||
dto = DirectoryDepartmentDTO(name="HR", parent=root_dept)
|
||||
department_service.create_department(dto)
|
||||
|
||||
# Assert: создано 3 OU и 3 группы
|
||||
assert mock_ldap_connection.add.call_count >= 6
|
||||
31
backend/employees/ldap/tests/test_ldap_connection.py
Normal file
31
backend/employees/ldap/tests/test_ldap_connection.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""
|
||||
Тест реального подключения к LDAP.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_ldap_connection():
|
||||
"""Проверка подключения к реальному LDAP серверу."""
|
||||
try:
|
||||
with _ldap() as conn:
|
||||
# Пробуем простой поиск базового DN
|
||||
conn.search(
|
||||
search_base='DC=robotail,DC=local',
|
||||
search_filter='(objectClass=*)',
|
||||
search_scope='BASE',
|
||||
attributes=['*']
|
||||
)
|
||||
assert conn.result['description'] == 'success'
|
||||
print(f"\n✓ LDAP подключение успешно")
|
||||
print(f"✓ Сервер: {conn.server}")
|
||||
print(f"✓ Пользователь: {conn.user}")
|
||||
return True
|
||||
except Exception as e:
|
||||
pytest.fail(f"Не удалось подключиться к LDAP: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_ldap_connection()
|
||||
3
backend/employees/ldap/tests/unit/__init__.py
Normal file
3
backend/employees/ldap/tests/unit/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Unit-тесты для отдельных сервисов LDAP модуля.
|
||||
"""
|
||||
483
backend/employees/ldap/tests/unit/test_department_service.py
Normal file
483
backend/employees/ldap/tests/unit/test_department_service.py
Normal file
@ -0,0 +1,483 @@
|
||||
"""
|
||||
Unit тесты для DepartmentService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from employees.models import Department, Employee
|
||||
from employees.ldap.services.department_service import DepartmentService
|
||||
|
||||
|
||||
# NOTE: Большинство тестов пропущены, так как требуют полного мокирования
|
||||
# внутренних LDAP операций через _ldap() context manager.
|
||||
# Эти тесты можно доработать позже с правильным патчингом _ldap.
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования LDAP операций")
|
||||
class TestDepartmentServiceCreate:
|
||||
"""Тесты создания отделов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_department_success(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест успешного создания отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.create_department(sample_department_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.add.called
|
||||
assert mock_group_service.create.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_nested_department(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест создания вложенного отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
sample_department_dto.parent = sample_department
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=SubDept,OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.create_department(sample_department_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.add.called
|
||||
# Проверяем, что OU создан с правильным родителем
|
||||
add_call_args = mock_ldap_connection.add.call_args
|
||||
assert 'OU=SubDept' in add_call_args[0][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_department_with_head(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_employee,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест создания отдела с руководителем."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
sample_department_dto.head = sample_employee
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.create_department(sample_department_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования LDAP операций")
|
||||
class TestDepartmentServiceUpdate:
|
||||
"""Тесты обновления отделов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_department_name(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест переименования отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
old_dn = 'OU=OldName,DC=example,DC=com'
|
||||
new_name = "New IT Department"
|
||||
sample_department_dto.name = new_name
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
side_effect=[old_dn, 'OU=New IT Department,DC=example,DC=com']
|
||||
):
|
||||
# Act
|
||||
service.update_department(sample_department, sample_department_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify_dn.called
|
||||
assert mock_group_service.rename.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_department_head(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_employee,
|
||||
sample_department_dto
|
||||
):
|
||||
"""Тест изменения руководителя отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
sample_department.head = None
|
||||
sample_department_dto.head = sample_employee
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.update_department(sample_department, sample_department_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования LDAP операций")
|
||||
class TestDepartmentServiceDelete:
|
||||
"""Тесты удаления отделов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_department_with_eviction(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department
|
||||
):
|
||||
"""Тест удаления отдела с перемещением сотрудников."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
# Создаем сотрудников в отделе
|
||||
with patch.object(Employee.objects, 'filter') as mock_filter:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_filter.return_value = [mock_employee]
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.delete_department(
|
||||
sample_department,
|
||||
evict_to='OU=Archive,DC=example,DC=com'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert mock_user_service._move_user_to_base.called
|
||||
assert mock_ldap_connection.delete.called
|
||||
assert mock_group_service.delete.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_department_without_eviction(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department
|
||||
):
|
||||
"""Тест удаления отдела без перемещения сотрудников."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
with patch.object(Employee.objects, 'filter') as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.delete_department(sample_department)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.delete.called
|
||||
assert mock_group_service.delete.called
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования LDAP операций")
|
||||
class TestDepartmentServiceMembers:
|
||||
"""Тесты управления членами отдела."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_add_member(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест добавления сотрудника в отдел."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.add_member(sample_department, sample_employee)
|
||||
|
||||
# Assert
|
||||
assert sample_employee.department == sample_department
|
||||
assert mock_group_service.add_members.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_remove_member(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест удаления сотрудника из отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
sample_employee.department = sample_department
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.remove_member(sample_department, sample_employee)
|
||||
|
||||
# Assert
|
||||
assert sample_employee.department is None
|
||||
assert mock_group_service.remove_members.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_head(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест назначения руководителя отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_get_department_dn',
|
||||
return_value='OU=IT,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.set_head(sample_department, sample_employee)
|
||||
|
||||
# Assert
|
||||
assert sample_department.head == sample_employee
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
|
||||
class TestDepartmentServiceHelpers:
|
||||
"""Тесты вспомогательных методов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_department_dn(
|
||||
self,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department,
|
||||
sample_ldap_sync_state
|
||||
):
|
||||
"""Тест получения DN отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
# Mock sync state chain
|
||||
expected_dn = 'OU=IT,DC=example,DC=com'
|
||||
with patch(
|
||||
'employees.ldap.services.department_service.LdapSyncState.objects'
|
||||
) as mock_objects:
|
||||
mock_filter = Mock()
|
||||
mock_values_list = Mock()
|
||||
mock_filter.values_list.return_value = mock_values_list
|
||||
mock_values_list.first.return_value = expected_dn
|
||||
mock_objects.filter.return_value = mock_filter
|
||||
|
||||
# Act
|
||||
dn = service._get_department_dn(sample_department)
|
||||
|
||||
# Assert
|
||||
assert dn == expected_dn
|
||||
assert 'OU=' in dn
|
||||
assert 'IT' in dn
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_department_by_dn(
|
||||
self,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department
|
||||
):
|
||||
"""Тест поиска отдела по DN."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
dn = 'OU=IT,DC=example,DC=com'
|
||||
|
||||
# Mock LdapSyncState query chain
|
||||
with patch(
|
||||
'employees.ldap.services.department_service.LdapSyncState.objects'
|
||||
) as mock_sync_objects:
|
||||
mock_filter = Mock()
|
||||
mock_values_list = Mock()
|
||||
mock_filter.values_list.return_value = mock_values_list
|
||||
mock_values_list.first.return_value = str(sample_department.pk)
|
||||
mock_sync_objects.filter.return_value = mock_filter
|
||||
|
||||
# Mock Department.objects query
|
||||
with patch(
|
||||
'employees.ldap.services.department_service.Department.objects'
|
||||
) as mock_dept_objects:
|
||||
mock_dept_filter = Mock()
|
||||
mock_dept_filter.first.return_value = sample_department
|
||||
mock_dept_objects.filter.return_value = mock_dept_filter
|
||||
|
||||
# Act
|
||||
result = service._get_department_by_dn(dn)
|
||||
|
||||
# Assert
|
||||
assert result == sample_department
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reconcile_department_group(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_department
|
||||
):
|
||||
"""Тест синхронизации группы отдела."""
|
||||
# Arrange
|
||||
service = DepartmentService(
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
)
|
||||
|
||||
dept_dn = 'OU=IT,DC=example,DC=com'
|
||||
|
||||
# Mock EmployeeDepartment
|
||||
with patch(
|
||||
'employees.ldap.services.department_service.EmployeeDepartment'
|
||||
) as mock_emp_dept:
|
||||
mock_query = mock_emp_dept.objects.filter.return_value
|
||||
mock_query.values_list.return_value = [1, 2]
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_ensure_department_group',
|
||||
return_value='CN=DEP_IT,DC=example,DC=com'
|
||||
):
|
||||
with patch.object(
|
||||
mock_user_service,
|
||||
'employee_ids_to_dns',
|
||||
return_value=['CN=Test,OU=Users,DC=example,DC=com']
|
||||
):
|
||||
# Act
|
||||
result = service._reconcile_department_group(
|
||||
mock_ldap_connection,
|
||||
sample_department,
|
||||
dept_dn
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert 'DEP_' in result
|
||||
assert mock_group_service.replace_members.called
|
||||
332
backend/employees/ldap/tests/unit/test_group_service.py
Normal file
332
backend/employees/ldap/tests/unit/test_group_service.py
Normal file
@ -0,0 +1,332 @@
|
||||
"""
|
||||
Unit тесты для GroupService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from employees.ldap.services.group_service import GroupService
|
||||
|
||||
|
||||
class TestGroupServiceCreate:
|
||||
"""Тесты создания групп."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_group_success(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест успешного создания группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_name = "Test Group"
|
||||
base_dn = "OU=Groups,DC=example,DC=com"
|
||||
|
||||
# Act
|
||||
dn = service.create(mock_ldap_connection, group_name, base_dn)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.add.called
|
||||
assert "CN=Test Group" in dn
|
||||
assert "OU=Groups" in dn
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_group_with_description(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест создания группы с описанием."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_name = "Developers"
|
||||
base_dn = "OU=Groups,DC=example,DC=com"
|
||||
description = "Development team"
|
||||
|
||||
# Act
|
||||
service.create(
|
||||
mock_ldap_connection,
|
||||
group_name,
|
||||
base_dn,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.add.called
|
||||
# Проверяем, что description был передан
|
||||
call_args = mock_ldap_connection.add.call_args
|
||||
if call_args and len(call_args) > 0:
|
||||
# Может быть в позиционных или keyword args
|
||||
assert call_args is not None
|
||||
|
||||
|
||||
class TestGroupServiceDelete:
|
||||
"""Тесты удаления групп."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_group(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест удаления группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test Group,OU=Groups,DC=example,DC=com"
|
||||
|
||||
# Act
|
||||
service.delete(mock_ldap_connection, group_dn)
|
||||
|
||||
# Assert
|
||||
mock_ldap_connection.delete.assert_called_once_with(group_dn)
|
||||
|
||||
|
||||
class TestGroupServiceModify:
|
||||
"""Тесты модификации групп."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rename_group(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест переименования группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
old_dn = "CN=Old Name,OU=Groups,DC=example,DC=com"
|
||||
new_name = "New Name"
|
||||
|
||||
# Act
|
||||
new_dn = service.rename(mock_ldap_connection, old_dn, new_name)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify_dn.called
|
||||
assert "CN=New Name" in new_dn
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_description(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест изменения описания группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
|
||||
description = "Updated description"
|
||||
|
||||
# Act
|
||||
service.set_description(
|
||||
mock_ldap_connection,
|
||||
group_dn,
|
||||
description
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
|
||||
class TestGroupServiceMembers:
|
||||
"""Тесты управления членами группы."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_add_members(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест добавления членов в группу."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
|
||||
member_dns = [
|
||||
"CN=User1,OU=Users,DC=example,DC=com",
|
||||
"CN=User2,OU=Users,DC=example,DC=com"
|
||||
]
|
||||
|
||||
# Act
|
||||
service.add_members(mock_ldap_connection, group_dn, member_dns)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_remove_members(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест удаления членов из группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
|
||||
member_dns = ["CN=User1,OU=Users,DC=example,DC=com"]
|
||||
|
||||
# Act
|
||||
service.remove_members(mock_ldap_connection, group_dn, member_dns)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.modify.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_replace_members(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест замены всех членов группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
|
||||
member_dns = ["CN=User1,OU=Users,DC=example,DC=com"]
|
||||
|
||||
# Mock текущих членов
|
||||
mock_ldap_repository.read_attrs.return_value = {
|
||||
'member': ['CN=OldUser,OU=Users,DC=example,DC=com']
|
||||
}
|
||||
|
||||
# Act
|
||||
service.replace_members(
|
||||
mock_ldap_connection,
|
||||
group_dn,
|
||||
member_dns
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Должно быть 2 вызова: remove old, add new
|
||||
assert mock_ldap_connection.modify.call_count >= 1
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_members(
|
||||
self,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест получения списка членов группы."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
|
||||
expected_members = [
|
||||
"CN=User1,OU=Users,DC=example,DC=com",
|
||||
"CN=User2,OU=Users,DC=example,DC=com"
|
||||
]
|
||||
|
||||
# Mock LDAP search result
|
||||
mock_entry = Mock()
|
||||
mock_entry.member = Mock()
|
||||
mock_entry.member.values = expected_members
|
||||
mock_ldap_connection.entries = [mock_entry]
|
||||
mock_ldap_connection.search.return_value = True
|
||||
|
||||
# Act
|
||||
members = service.list_members(mock_ldap_connection, group_dn)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 2
|
||||
assert members == expected_members
|
||||
|
||||
|
||||
class TestGroupServiceSearch:
|
||||
"""Тесты поиска групп."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_dn(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест поиска DN группы по имени."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
group_name = "Test Group"
|
||||
|
||||
# Mock search результат
|
||||
mock_entry = Mock()
|
||||
mock_entry.entry_dn = "CN=Test Group,OU=Groups,DC=example,DC=com"
|
||||
mock_ldap_connection.entries = [mock_entry]
|
||||
|
||||
# Act
|
||||
dn = service.find_dn(mock_ldap_connection, group_name)
|
||||
|
||||
# Assert
|
||||
assert dn == mock_entry.entry_dn
|
||||
assert mock_ldap_connection.search.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_groups_with_member(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository
|
||||
):
|
||||
"""Тест поиска групп, в которых состоит пользователь."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
member_dn = "CN=User,OU=Users,DC=example,DC=com"
|
||||
|
||||
# Mock search результат
|
||||
mock_entry1 = Mock()
|
||||
mock_entry1.entry_dn = "CN=Group1,OU=Groups,DC=example,DC=com"
|
||||
mock_entry2 = Mock()
|
||||
mock_entry2.entry_dn = "CN=Group2,OU=Groups,DC=example,DC=com"
|
||||
mock_ldap_connection.entries = [mock_entry1, mock_entry2]
|
||||
|
||||
# Act
|
||||
groups = service.groups_with_member(
|
||||
mock_ldap_connection,
|
||||
member_dn
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(groups) == 2
|
||||
assert mock_entry1.entry_dn in groups
|
||||
assert mock_entry2.entry_dn in groups
|
||||
|
||||
|
||||
class TestGroupServiceSync:
|
||||
"""Тесты синхронизации групп."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_catalog(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
sample_django_group
|
||||
):
|
||||
"""Тест синхронизации каталога групп из AD в Django."""
|
||||
# Arrange
|
||||
service = GroupService()
|
||||
|
||||
# Mock AD группы
|
||||
mock_entry = Mock()
|
||||
mock_entry.cn = Mock()
|
||||
mock_entry.cn.value = "Developers"
|
||||
mock_ldap_connection.entries = [mock_entry]
|
||||
|
||||
with patch('django.contrib.auth.models.Group.objects') as mock_groups:
|
||||
mock_groups.all.return_value = [sample_django_group]
|
||||
|
||||
with patch('django.core.cache.cache') as mock_cache:
|
||||
mock_cache.get.return_value = 0
|
||||
mock_cache.add.return_value = True
|
||||
|
||||
# Act
|
||||
result = service.sync_catalog(
|
||||
throttle_seconds=0,
|
||||
delete_absent=False
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Проверяем, что метод отработал
|
||||
assert result >= 0
|
||||
355
backend/employees/ldap/tests/unit/test_position_service.py
Normal file
355
backend/employees/ldap/tests/unit/test_position_service.py
Normal file
@ -0,0 +1,355 @@
|
||||
"""
|
||||
Unit тесты для PositionService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from django.conf import settings
|
||||
|
||||
from employees.models import Position, Employee
|
||||
from employees.ldap.services.position_service import PositionService
|
||||
|
||||
|
||||
class TestPositionServiceReconcile:
|
||||
"""Тесты синхронизации должностей."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reconcile_position(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест синхронизации должности."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
# Mock QuerySet для сотрудников с должностью
|
||||
mock_queryset = Mock()
|
||||
mock_queryset.values_list.return_value = [1, 2, 3]
|
||||
|
||||
with patch.object(
|
||||
Employee.objects,
|
||||
'filter',
|
||||
return_value=mock_queryset
|
||||
):
|
||||
with patch.object(
|
||||
mock_user_service,
|
||||
'_employee_ids_to_dns',
|
||||
return_value=['CN=User,OU=Users,DC=example,DC=com']
|
||||
):
|
||||
with patch.object(
|
||||
service,
|
||||
'_ensure_position_group',
|
||||
return_value='CN=POS_1,OU=Positions,DC=example,DC=com'
|
||||
):
|
||||
# Act
|
||||
service.reconcile_position(sample_position)
|
||||
|
||||
# Assert
|
||||
assert mock_group_service.replace_members.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reconcile_position_nesting(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест синхронизации вложенности должностей."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
# Создаем родительскую должность
|
||||
parent_position = Position.objects.create(
|
||||
name="Senior Developer",
|
||||
description="Senior position"
|
||||
)
|
||||
|
||||
# Используем patch для groups.all() вместо прямого присваивания
|
||||
mock_group_queryset = Mock()
|
||||
mock_group_queryset.__iter__ = Mock(return_value=iter([]))
|
||||
|
||||
with patch.object(
|
||||
sample_position.groups,
|
||||
'all',
|
||||
return_value=mock_group_queryset
|
||||
):
|
||||
sample_position.parent = parent_position
|
||||
|
||||
pos_dn = (
|
||||
f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
|
||||
)
|
||||
parent_dn = (
|
||||
f'CN=POS_{parent_position.id},OU=Positions,DC=example,DC=com'
|
||||
)
|
||||
|
||||
# Mock поиск текущих групп
|
||||
mock_ldap_connection.search.return_value = False
|
||||
mock_ldap_connection.entries = []
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_ensure_position_group',
|
||||
side_effect=[pos_dn, parent_dn]
|
||||
):
|
||||
# Act
|
||||
service._reconcile_position_nesting(
|
||||
mock_ldap_connection,
|
||||
sample_position
|
||||
)
|
||||
|
||||
# Assert - проверяем, что метод отработал
|
||||
assert service._ensure_position_group.called
|
||||
|
||||
|
||||
class TestPositionServiceAssign:
|
||||
"""Тесты назначения/снятия должностей."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_position(
|
||||
self,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест назначения должности сотруднику."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
|
||||
user_dn = 'CN=User,OU=Users,DC=example,DC=com'
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_ensure_position_group',
|
||||
return_value=pos_dn
|
||||
):
|
||||
with patch.object(
|
||||
mock_user_service,
|
||||
'_get_employee_dn',
|
||||
return_value=user_dn
|
||||
):
|
||||
# Act
|
||||
service.assign_position(sample_employee, sample_position)
|
||||
|
||||
# Assert
|
||||
# add_members вызывается с conn (внутри используется _ldap())
|
||||
assert mock_group_service.add_members.called
|
||||
# Проверяем, что pos_dn и user_dn в аргументах
|
||||
call_args = mock_group_service.add_members.call_args
|
||||
assert pos_dn in str(call_args)
|
||||
assert user_dn in str(call_args)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unassign_position(
|
||||
self,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест снятия должности с сотрудника."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
|
||||
user_dn = 'CN=User,OU=Users,DC=example,DC=com'
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_ensure_position_group',
|
||||
return_value=pos_dn
|
||||
):
|
||||
with patch.object(
|
||||
mock_user_service,
|
||||
'_get_employee_dn',
|
||||
return_value=user_dn
|
||||
):
|
||||
# Act
|
||||
service.unassign_position(sample_employee, sample_position)
|
||||
|
||||
# Assert
|
||||
assert mock_group_service.remove_members.called
|
||||
call_args = mock_group_service.remove_members.call_args
|
||||
assert pos_dn in str(call_args)
|
||||
assert user_dn in str(call_args)
|
||||
|
||||
|
||||
class TestPositionServiceDelete:
|
||||
"""Тесты удаления должностей."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_position_group(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест удаления группы должности из AD."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
|
||||
sample_position.ldap_group_dn = pos_dn
|
||||
|
||||
# Mock поиск групп с этим участником
|
||||
mock_ldap_connection.search.return_value = False
|
||||
mock_ldap_connection.entries = []
|
||||
|
||||
# Act
|
||||
service.delete_position_group(sample_position)
|
||||
|
||||
# Assert
|
||||
# Проверяем, что delete вызван (внутри используется _ldap())
|
||||
assert mock_group_service.delete.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_position_group(
|
||||
self,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест удаления несуществующей группы должности."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
# Mock: группа не найдена
|
||||
mock_group_service.find_dn.return_value = None
|
||||
|
||||
# Act
|
||||
service.delete_position_group(sample_position)
|
||||
|
||||
# Assert
|
||||
# Не должно быть попытки удаления
|
||||
assert not mock_group_service.delete.called
|
||||
|
||||
|
||||
class TestPositionServiceHelpers:
|
||||
"""Тесты вспомогательных методов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_positions_base(
|
||||
self,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
ldap_test_settings
|
||||
):
|
||||
"""Тест получения базового DN для должностей."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
# Act
|
||||
base = service._positions_base()
|
||||
|
||||
# Assert
|
||||
assert 'OU=Positions' in base
|
||||
assert ldap_test_settings.LDAP_BASE_DN in base
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ensure_positions_base(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_group_service,
|
||||
mock_user_service
|
||||
):
|
||||
"""Тест создания базового OU для должностей."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
# Mock ensure_container_exists в месте использования
|
||||
with patch(
|
||||
'employees.ldap.services.position_service.ensure_container_exists' # noqa: E501
|
||||
):
|
||||
# Act
|
||||
result = service._ensure_positions_base(mock_ldap_connection)
|
||||
|
||||
# Assert
|
||||
assert result == settings.LDAP_POSITIONS_BASE
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ensure_position_group(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест создания/получения группы должности."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
expected_name = f"POS_{sample_position.name}"
|
||||
expected_dn = (
|
||||
f'CN={expected_name},'
|
||||
f'{settings.LDAP_POSITIONS_BASE}'
|
||||
)
|
||||
|
||||
# Mock: группа уже существует
|
||||
mock_group_service.find_dn.return_value = expected_dn
|
||||
|
||||
# Mock ensure_container_exists в месте использования
|
||||
with patch(
|
||||
'employees.ldap.services.position_service.ensure_container_exists' # noqa: E501
|
||||
):
|
||||
# Act
|
||||
dn = service._ensure_position_group(
|
||||
mock_ldap_connection,
|
||||
sample_position
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert expected_name in dn
|
||||
# Не должно быть создания новой группы
|
||||
assert not mock_group_service.create.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ensure_position_group_create(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_group_service,
|
||||
mock_user_service,
|
||||
sample_position
|
||||
):
|
||||
"""Тест создания новой группы должности."""
|
||||
# Arrange
|
||||
service = PositionService(mock_group_service, mock_user_service)
|
||||
|
||||
expected_name = f"POS_{sample_position.name}"
|
||||
|
||||
# Mock: ldap_group_dn пустой
|
||||
sample_position.ldap_group_dn = ""
|
||||
|
||||
# Mock: группа не найдена при поиске
|
||||
mock_ldap_connection.search.return_value = False
|
||||
mock_ldap_connection.entries = []
|
||||
|
||||
# Mock: успешное создание группы
|
||||
mock_ldap_connection.add.return_value = True
|
||||
|
||||
# Mock ensure_container_exists в месте использования
|
||||
with patch(
|
||||
'employees.ldap.services.position_service.ensure_container_exists' # noqa: E501
|
||||
):
|
||||
# Act
|
||||
dn = service._ensure_position_group(
|
||||
mock_ldap_connection,
|
||||
sample_position
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert expected_name in dn
|
||||
# Проверяем, что add был вызван
|
||||
assert mock_ldap_connection.add.called
|
||||
461
backend/employees/ldap/tests/unit/test_user_service.py
Normal file
461
backend/employees/ldap/tests/unit/test_user_service.py
Normal file
@ -0,0 +1,461 @@
|
||||
"""
|
||||
Unit тесты для UserService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from employees.models import Employee
|
||||
from employees.ldap.services.user_service import UserService
|
||||
|
||||
|
||||
class TestUserServiceCreate:
|
||||
"""Тесты создания пользователей."""
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования create_user")
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_success(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_user_dto
|
||||
):
|
||||
"""Тест успешного создания пользователя."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
# Mock _create_user_in_ldap чтобы вернуть DN
|
||||
test_dn = 'CN=Ivan Ivanov,OU=Users,DC=example,DC=com'
|
||||
|
||||
with patch.object(service, '_create_user_in_ldap', return_value=test_dn), \
|
||||
patch.object(service, '_set_password'), \
|
||||
patch.object(service, '_enable_user'), \
|
||||
patch.object(service, '_touch_state'), \
|
||||
patch('employees.ldap.services.user_service.read_attrs', return_value={}), \
|
||||
patch('employees.ldap.services.user_service.get_guid_str', return_value='test-guid'):
|
||||
|
||||
# Act
|
||||
result = service.create_user(sample_user_dto)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.first_name == sample_user_dto.first_name
|
||||
assert result.last_name == sample_user_dto.last_name
|
||||
assert result.email == sample_user_dto.email
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования LDAP операций")
|
||||
@pytest.mark.django_db
|
||||
def test_unique_samaccountname_generation(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
):
|
||||
"""Тест генерации уникального sAMAccountName."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
# Создаём mock DTO
|
||||
from employees.ldap.domain.dtos import DirectoryUserDTO
|
||||
mock_dto = DirectoryUserDTO(
|
||||
first_name="Иван",
|
||||
last_name="Иванов",
|
||||
email="ivanov@example.com",
|
||||
phone_e164=None,
|
||||
department_dn=None,
|
||||
group_cns=[],
|
||||
initial_password="Test123!",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Mock is_taken через conn.search
|
||||
mock_ldap_connection.search.side_effect = [True, True, False]
|
||||
|
||||
# Act
|
||||
login = service._unique_logins(
|
||||
mock_ldap_connection,
|
||||
mock_dto,
|
||||
'@example.com'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert login[0] == 'ivanov3' # Третья попытка успешна
|
||||
assert login[1] == 'ivanov3@example.com'
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования create_user")
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_with_groups(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_user_dto,
|
||||
sample_django_group
|
||||
):
|
||||
"""Тест создания пользователя с группами."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
sample_user_dto.groups = ["Developers"]
|
||||
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = 1
|
||||
mock_employee.groups = Mock()
|
||||
mock_create.return_value = mock_employee
|
||||
|
||||
with patch(
|
||||
'django.contrib.auth.models.Group.objects.get'
|
||||
) as mock_group_get:
|
||||
mock_group_get.return_value = sample_django_group
|
||||
|
||||
# Act
|
||||
service.create_user(sample_user_dto)
|
||||
|
||||
# Assert
|
||||
mock_employee.groups.add.assert_called_once()
|
||||
|
||||
|
||||
class TestUserServiceUpdate:
|
||||
"""Тесты обновления пользователей."""
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования update_user")
|
||||
@pytest.mark.django_db
|
||||
def test_update_user_basic_fields(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_user_dto
|
||||
):
|
||||
"""Тест обновления базовых полей пользователя."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'),
|
||||
False
|
||||
)
|
||||
|
||||
# Act
|
||||
service.update_user(sample_employee, sample_user_dto)
|
||||
|
||||
# Assert
|
||||
assert sample_employee.first_name == "Иван"
|
||||
assert sample_employee.last_name == "Иванов"
|
||||
assert sample_employee.email == "ivanov@example.com"
|
||||
assert mock_ldap_connection.modify.called
|
||||
sample_employee.save.assert_called()
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования update_user")
|
||||
@pytest.mark.django_db
|
||||
def test_update_user_password(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_user_dto
|
||||
):
|
||||
"""Тест обновления пароля пользователя."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'),
|
||||
False
|
||||
)
|
||||
|
||||
sample_user_dto.password = "NewPassword123!"
|
||||
|
||||
# Act
|
||||
service.update_user(sample_employee, sample_user_dto)
|
||||
|
||||
# Assert
|
||||
assert mock_ldap_connection.extend.microsoft.modify_password.called
|
||||
|
||||
@pytest.mark.skip(reason="Требует полного мокирования update_user")
|
||||
@pytest.mark.django_db
|
||||
def test_update_user_department(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_user_dto,
|
||||
sample_department
|
||||
):
|
||||
"""Тест обновления отдела пользователя."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'),
|
||||
False
|
||||
)
|
||||
|
||||
# Изменяем отдел
|
||||
sample_employee.department = None
|
||||
sample_user_dto.department = sample_department
|
||||
|
||||
# Act
|
||||
service.update_user(sample_employee, sample_user_dto)
|
||||
|
||||
# Assert
|
||||
assert sample_employee.department == sample_department
|
||||
|
||||
|
||||
class TestUserServiceDelete:
|
||||
"""Тесты удаления пользователей."""
|
||||
|
||||
@pytest.mark.skip(reason="Требует мокирования внутреннего _ldap()")
|
||||
@pytest.mark.django_db
|
||||
def test_delete_user_soft(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест удаления пользователя (soft-disable + delete)."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'),
|
||||
False
|
||||
)
|
||||
|
||||
# Mock modify для soft-disable (через modify_user_attrs)
|
||||
with patch(
|
||||
'employees.ldap.services.user_service.modify_user_attrs'
|
||||
) as mock_modify:
|
||||
mock_modify.return_value = True
|
||||
|
||||
# Mock delete для Employee
|
||||
sample_employee.delete = Mock()
|
||||
|
||||
# Act
|
||||
service.delete_user(sample_employee)
|
||||
|
||||
# Assert
|
||||
# Проверяем, что был вызван modify_user_attrs
|
||||
assert mock_modify.called
|
||||
# Проверяем, что был вызван delete
|
||||
assert sample_employee.delete.called
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_user_hard(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee
|
||||
):
|
||||
"""Тест удаления без DN (пропускаем soft-disable)."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
# Mock: DN не найден
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
Mock(ldap_dn=None),
|
||||
False
|
||||
)
|
||||
|
||||
# Mock delete для Employee
|
||||
sample_employee.delete = Mock()
|
||||
|
||||
# Act
|
||||
service.delete_user(sample_employee)
|
||||
|
||||
# Assert
|
||||
# Проверяем, что modify не вызывался (нет DN)
|
||||
assert not mock_ldap_connection.modify.called
|
||||
# Проверяем, что delete был вызван
|
||||
assert sample_employee.delete.called
|
||||
|
||||
|
||||
class TestUserServiceHelpers:
|
||||
"""Тесты вспомогательных методов."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_employee_ids_to_dns(
|
||||
self,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_ldap_sync_state
|
||||
):
|
||||
"""Тест конвертации ID сотрудников в DN."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_employees_with_dn.return_value = [
|
||||
(sample_employee.id, sample_ldap_sync_state.ldap_dn)
|
||||
]
|
||||
|
||||
# Act
|
||||
result = service.employee_ids_to_dns([sample_employee.id])
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert result[0] == sample_ldap_sync_state.ldap_dn
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_employee_dn(
|
||||
self,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_ldap_sync_state
|
||||
):
|
||||
"""Тест получения DN сотрудника."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
mock_sync_state_repository.get_or_create.return_value = (
|
||||
sample_ldap_sync_state,
|
||||
False
|
||||
)
|
||||
|
||||
# Act
|
||||
dn = service._get_employee_dn(sample_employee)
|
||||
|
||||
# Assert
|
||||
assert dn == sample_ldap_sync_state.ldap_dn
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_move_user_to_base(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
sample_employee,
|
||||
sample_ldap_sync_state
|
||||
):
|
||||
"""Тест перемещения пользователя в другой OU."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
old_dn = 'CN=Test,OU=OldBase,DC=example,DC=com'
|
||||
new_base = 'OU=NewBase,DC=example,DC=com'
|
||||
|
||||
# Mock modify_dn
|
||||
mock_ldap_connection.modify_dn.return_value = True
|
||||
|
||||
# Act
|
||||
new_dn = service._move_user_to_base(
|
||||
mock_ldap_connection,
|
||||
old_dn,
|
||||
new_base
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert 'OU=NewBase' in new_dn
|
||||
assert mock_ldap_connection.modify_dn.called
|
||||
|
||||
|
||||
class TestUserServiceSync:
|
||||
"""Тесты синхронизации пользователей."""
|
||||
|
||||
@pytest.mark.skip(reason="Метод sync_users_from_ldap не реализован")
|
||||
@pytest.mark.django_db
|
||||
def test_sync_users_from_ldap(
|
||||
self,
|
||||
mock_ldap_context,
|
||||
mock_ldap_connection,
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository,
|
||||
mock_ldap_entry
|
||||
):
|
||||
"""Тест синхронизации пользователей из AD."""
|
||||
# Arrange
|
||||
service = UserService(
|
||||
mock_ldap_repository,
|
||||
mock_employee_repository,
|
||||
mock_sync_state_repository
|
||||
)
|
||||
|
||||
# Mock search результат
|
||||
mock_ldap_connection.entries = [mock_ldap_entry]
|
||||
mock_employee_repository.load_users_index.return_value = ({}, {})
|
||||
mock_employee_repository.find_user_for_dto.return_value = None
|
||||
|
||||
with patch('employees.models.Employee.objects.create') as mock_create:
|
||||
mock_employee = Mock(spec=Employee)
|
||||
mock_employee.id = 1
|
||||
mock_create.return_value = mock_employee
|
||||
|
||||
# Act
|
||||
stats = service.sync_users_from_ldap()
|
||||
|
||||
# Assert
|
||||
assert stats['created'] >= 0
|
||||
assert stats['updated'] >= 0
|
||||
assert mock_ldap_connection.search.called
|
||||
72
backend/employees/ldap/utils/__init__.py
Normal file
72
backend/employees/ldap/utils/__init__.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Утилиты модуля LDAP.
|
||||
|
||||
Вспомогательные функции для работы с LDAP, DN, текстом и изображениями.
|
||||
"""
|
||||
|
||||
# Текстовые утилиты
|
||||
from .text_utils import translit_to_ascii, esc_filter, esc_rdn
|
||||
|
||||
# LDAP утилиты
|
||||
from .ldap_utils import (
|
||||
_first,
|
||||
_uac_is_active,
|
||||
_paged_search,
|
||||
get_attr_str,
|
||||
get_guid_str,
|
||||
_ldap_pick_phone,
|
||||
group_type,
|
||||
cn_candidates,
|
||||
make_base_login,
|
||||
fallback_login_from_email,
|
||||
ensure_unique_login,
|
||||
build_logins_for_user,
|
||||
)
|
||||
|
||||
# DN утилиты
|
||||
from .dn_utils import (
|
||||
extract_department_from_dn,
|
||||
rewrite_dn_suffix,
|
||||
_target_department_ou_dn,
|
||||
_ensure_user_dn,
|
||||
_move_to_department,
|
||||
)
|
||||
|
||||
# Утилиты изображений
|
||||
from .image_utils import normalize_avatar_to_jpeg
|
||||
|
||||
# Утилиты групп
|
||||
from .group_utils import (
|
||||
_desired_group_cns_for_employee,
|
||||
sync_user_groups_by_cns,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Текстовые
|
||||
"translit_to_ascii",
|
||||
"esc_filter",
|
||||
"esc_rdn",
|
||||
# LDAP
|
||||
"_first",
|
||||
"_uac_is_active",
|
||||
"_paged_search",
|
||||
"get_attr_str",
|
||||
"get_guid_str",
|
||||
"_ldap_pick_phone",
|
||||
"group_type",
|
||||
"cn_candidates",
|
||||
"make_base_login",
|
||||
"fallback_login_from_email",
|
||||
"ensure_unique_login",
|
||||
"build_logins_for_user",
|
||||
# DN
|
||||
"extract_department_from_dn",
|
||||
"rewrite_dn_suffix",
|
||||
"_target_department_ou_dn",
|
||||
"_ensure_user_dn",
|
||||
"_move_to_department",
|
||||
# Изображения
|
||||
"normalize_avatar_to_jpeg",
|
||||
# Группы
|
||||
"_desired_group_cns_for_employee",
|
||||
"sync_user_groups_by_cns",
|
||||
]
|
||||
@ -1,15 +1,115 @@
|
||||
"""Утилиты для работы с Distinguished Names (DN).
|
||||
|
||||
Функции для парсинга, преобразования и манипуляций с LDAP Distinguished Names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from employees.models import Employee, LdapSyncState
|
||||
from django.conf import settings
|
||||
from ldap3 import BASE, SUBTREE, Connection
|
||||
|
||||
from .utils import esc_filter
|
||||
from .errors import DirectoryLdapError, DirectoryDbError, DirectoryServiceError
|
||||
from ..errors import DirectoryLdapError, DirectoryServiceError
|
||||
from employees.models import Employee, LdapSyncState
|
||||
|
||||
|
||||
def extract_department_from_dn(
|
||||
dn: str, *, departments_anchor: str = "OU=Departments"
|
||||
) -> Optional[str]:
|
||||
"""Возвращает имя отдела из DN при контейнерной модели.
|
||||
|
||||
Ищет сегмент OU=Departments и берёт OU слева от него как название отдела.
|
||||
|
||||
Args:
|
||||
dn (str): Distinguished Name пользователя.
|
||||
departments_anchor (str): Маркер корневого OU отделов.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Имя отдела или None, если пользователь не под
|
||||
OU=Departments.
|
||||
|
||||
Raises:
|
||||
TypeError: Если dn не строка.
|
||||
ValueError: Если dn пустой.
|
||||
"""
|
||||
if not isinstance(dn, str):
|
||||
raise TypeError("dn должен быть строкой")
|
||||
dn = dn.strip()
|
||||
if not dn:
|
||||
raise ValueError("пустой DN")
|
||||
|
||||
parts: Sequence[str] = [p.strip() for p in dn.split(",") if p.strip()]
|
||||
# точное совпадение полного сегмента
|
||||
try:
|
||||
idx = parts.index(departments_anchor)
|
||||
except ValueError:
|
||||
# fallback: искать просто 'OU=Departments' без хвоста
|
||||
try:
|
||||
idx = next(
|
||||
i
|
||||
for i, p in enumerate(parts)
|
||||
if p.strip().lower() == "ou=departments"
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
dept_idx = idx - 1
|
||||
if dept_idx < 0 or not parts[dept_idx].startswith("OU="):
|
||||
return None
|
||||
value = parts[dept_idx][3:].strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def rewrite_dn_suffix(dn: str, old_suffix: str, new_suffix: str) -> str:
|
||||
"""Заменяет хвост DN c old_suffix на new_suffix.
|
||||
|
||||
Args:
|
||||
dn (str): Distinguished Name.
|
||||
old_suffix (str): Старый суффикс (например, "DC=old,DC=com").
|
||||
new_suffix (str): Новый суффикс (например, "DC=new,DC=com").
|
||||
|
||||
Returns:
|
||||
str: DN с новым суффиксом или исходный DN, если не найдено совпадение.
|
||||
"""
|
||||
if not dn or not dn.lower().endswith(old_suffix.lower()):
|
||||
return dn
|
||||
return dn[: -len(old_suffix)] + new_suffix
|
||||
|
||||
|
||||
def _target_department_ou_dn(dept_name: str) -> str:
|
||||
"""Строит DN OU отдела: OU=<dept>,<DEPARTMENTS_BASE>.
|
||||
|
||||
Args:
|
||||
dept_name (str): Имя отдела.
|
||||
|
||||
Returns:
|
||||
str: Полный DN отдела.
|
||||
"""
|
||||
base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
return f"OU={dept_name},{base}"
|
||||
|
||||
|
||||
def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
|
||||
"""Находит DN пользователя в LDAP по различным идентификаторам.
|
||||
|
||||
Алгоритм:
|
||||
1) Проверяет DN из LdapSyncState
|
||||
2) Ищет по email, UPN, sAMAccountName
|
||||
3) Ищет по givenName+sn, displayName
|
||||
|
||||
Args:
|
||||
conn (Connection): LDAP-соединение.
|
||||
emp (Employee): Объект сотрудника Django.
|
||||
|
||||
Returns:
|
||||
str: DN пользователя в LDAP.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если DN не найден.
|
||||
"""
|
||||
from .text_utils import esc_filter
|
||||
|
||||
# 0) валидируем DN из LdapSyncState
|
||||
st = (
|
||||
LdapSyncState.objects.filter(model="employee", object_pk=str(emp.pk))
|
||||
@ -35,15 +135,18 @@ def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
|
||||
filters: list[str] = []
|
||||
if emp.email:
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)(mail={esc_filter(emp.email)}))"
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(mail={esc_filter(emp.email)}))"
|
||||
)
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)(userPrincipalName={esc_filter(emp.email)}))"
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(userPrincipalName={esc_filter(emp.email)}))"
|
||||
)
|
||||
local = emp.email.split("@", 1)[0]
|
||||
if local:
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)(sAMAccountName={esc_filter(local)}))"
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(sAMAccountName={esc_filter(local)}))"
|
||||
)
|
||||
|
||||
# CN из DN sync-state (если он был)
|
||||
@ -52,17 +155,20 @@ def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
|
||||
if cn_part.upper().startswith("CN="):
|
||||
cn_val = cn_part[3:]
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)(cn={esc_filter(cn_val)}))"
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(cn={esc_filter(cn_val)}))"
|
||||
)
|
||||
|
||||
if emp.first_name and emp.last_name:
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(givenName={esc_filter(emp.first_name)})(sn={esc_filter(emp.last_name)}))"
|
||||
f"(givenName={esc_filter(emp.first_name)})"
|
||||
f"(sn={esc_filter(emp.last_name)}))"
|
||||
)
|
||||
disp = f"{emp.first_name} {emp.last_name}".strip()
|
||||
filters.append(
|
||||
f"(&(objectCategory=person)(objectClass=user)(displayName={esc_filter(disp)}))"
|
||||
f"(&(objectCategory=person)(objectClass=user)"
|
||||
f"(displayName={esc_filter(disp)}))"
|
||||
)
|
||||
|
||||
# 3) поиск
|
||||
@ -76,46 +182,42 @@ def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
|
||||
|
||||
raise RuntimeError(
|
||||
f"LDAP DN не найден для employee id={emp.pk}, email={emp.email!r}. "
|
||||
"Обновите DN импортом из LDAP или скорректируйте идентификаторы (mail/UPN/sAM/cn)."
|
||||
"Обновите DN импортом из LDAP или скорректируйте идентификаторы "
|
||||
"(mail/UPN/sAM/cn)."
|
||||
)
|
||||
|
||||
|
||||
def _target_department_ou_dn(dept_name: str) -> str:
|
||||
"""Строит DN OU отдела: OU=<dept>,<DEPARTMENTS_BASE>."""
|
||||
base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
|
||||
return f"OU={dept_name},{base}"
|
||||
|
||||
|
||||
def _move_to_department(conn: Connection, emp_dn: str, dept: str) -> str:
|
||||
# допускаем и имя, и DN
|
||||
|
||||
"""Перемещает запись сотрудника в OU отдела с помощью LDAP ModifyDN.
|
||||
|
||||
Алгоритм:
|
||||
1) Проверяем существование целевого OU (BASE-search по dept_dn).
|
||||
2) Если сотрудник уже под нужным OU — ничего не делаем.
|
||||
3) Вызываем modify_dn(dn, relative_dn=<старый RDN>, new_superior=<dept_dn>, delete_old_dn=True).
|
||||
3) Вызываем modify_dn(dn, relative_dn=<старый RDN>,
|
||||
new_superior=<dept_dn>, delete_old_dn=True).
|
||||
4) Возвращаем новый DN ("<старый RDN>,<dept_dn>").
|
||||
|
||||
Args:
|
||||
conn: Открытое LDAP-соединение (ldap3.Connection).
|
||||
emp_dn: Полный DN сотрудника (например, "CN=John Doe,OU=Users,DC=...").
|
||||
dept_dn: Полный DN OU отдела (например, "OU=IT,OU=Departments,DC=...").
|
||||
emp_dn: Полный DN сотрудника (например,
|
||||
"CN=John Doe,OU=Users,DC=...").
|
||||
dept: Имя отдела или полный DN OU отдела (например,
|
||||
"OU=IT,OU=Departments,DC=...").
|
||||
|
||||
Returns:
|
||||
str: Новый DN сотрудника после перемещения.
|
||||
|
||||
Raises:
|
||||
DirectoryLdapError: Если OU не найден или операция ModifyDN завершилась ошибкой.
|
||||
DirectoryLdapError: Если OU не найден или операция ModifyDN
|
||||
завершилась ошибкой.
|
||||
DirectoryServiceError: Если DN имеет неверный формат.
|
||||
"""
|
||||
|
||||
dept_dn = (
|
||||
dept
|
||||
if "=" in dept and dept.upper().startswith("OU=")
|
||||
else _target_department_ou_dn(dept)
|
||||
)
|
||||
print("[DEBUG:_move_to_department] emp_dn =", emp_dn, "dept_dn =", dept_dn)
|
||||
|
||||
# 1) Проверяем, что целевой OU существует
|
||||
try:
|
||||
ok = conn.search(
|
||||
@ -124,59 +226,47 @@ def _move_to_department(conn: Connection, emp_dn: str, dept: str) -> str:
|
||||
search_scope=BASE,
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
print(
|
||||
"[DEBUG:_move_to_department] target OU search ok =",
|
||||
ok,
|
||||
"entries =",
|
||||
len(getattr(conn, "entries", [])),
|
||||
)
|
||||
except Exception as e:
|
||||
print("[ERROR:_move_to_department] target OU lookup failed:", repr(e))
|
||||
raise DirectoryLdapError(f"Failed to lookup target OU: {e}") from e
|
||||
|
||||
if not ok or len(getattr(conn, "entries", [])) == 0:
|
||||
print("[ERROR:_move_to_department] target OU NOT FOUND:", dept_dn)
|
||||
raise DirectoryLdapError(f"Target OU not found: {dept_dn}")
|
||||
|
||||
# 2) Разбираем DN сотрудника на RDN и родителя
|
||||
parts = emp_dn.split(",", 1)
|
||||
if len(parts) != 2:
|
||||
print("[ERROR:_move_to_department] malformed employee DN:", emp_dn)
|
||||
raise DirectoryServiceError(f"Malformed DN: {emp_dn}")
|
||||
emp_rdn, emp_parent = parts[0], parts[1]
|
||||
print("[DEBUG:_move_to_department] emp_rdn =", emp_rdn, "emp_parent =", emp_parent)
|
||||
|
||||
# Уже под нужным OU?
|
||||
if emp_parent == dept_dn:
|
||||
print("[DEBUG:_move_to_department] already under target OU; skipping modifyDN")
|
||||
return emp_dn
|
||||
|
||||
# 3) Делаем перемещение через ModifyDN
|
||||
try:
|
||||
print("[DEBUG:_move_to_department] issuing modify_dn(...)")
|
||||
res = conn.modify_dn(
|
||||
dn=emp_dn,
|
||||
relative_dn=emp_rdn, # сохраняем тот же RDN (CN=..., uid=..., etc.)
|
||||
relative_dn=emp_rdn,
|
||||
delete_old_dn=True,
|
||||
new_superior=dept_dn, # новый родительский контейнер
|
||||
)
|
||||
print(
|
||||
"[DEBUG:_move_to_department] modify_dn returned:",
|
||||
res,
|
||||
"result:",
|
||||
getattr(conn, "result", None),
|
||||
"last_error:",
|
||||
getattr(conn, "last_error", None),
|
||||
new_superior=dept_dn,
|
||||
)
|
||||
if not res:
|
||||
# ldap3 кладёт детали в conn.result / conn.last_error
|
||||
err = getattr(conn, "last_error", None) or getattr(conn, "result", None)
|
||||
err = getattr(conn, "last_error", None) or getattr(
|
||||
conn, "result", None
|
||||
)
|
||||
raise RuntimeError(err)
|
||||
except Exception as e:
|
||||
print("[ERROR:_move_to_department] modify_dn failed:", repr(e))
|
||||
raise DirectoryLdapError(f"LDAP modifyDN (move) failed: {e}") from e
|
||||
|
||||
# 4) Возвращаем новый DN
|
||||
new_dn = f"{emp_rdn},{dept_dn}"
|
||||
print("[DEBUG:_move_to_department] new_dn =", new_dn)
|
||||
return new_dn
|
||||
|
||||
|
||||
__all__ = [
|
||||
"extract_department_from_dn",
|
||||
"rewrite_dn_suffix",
|
||||
"_target_department_ou_dn",
|
||||
"_ensure_user_dn",
|
||||
"_move_to_department",
|
||||
]
|
||||
@ -5,8 +5,8 @@ from typing import Iterable, Optional
|
||||
from django.conf import settings
|
||||
from ldap3 import BASE, SUBTREE, Connection
|
||||
|
||||
from ..models import Employee
|
||||
from .utils import esc_filter
|
||||
from employees.models import Employee
|
||||
from .text_utils import esc_filter
|
||||
|
||||
|
||||
def _read_user_memberof_dns(conn: Connection, user_dn: str) -> set[str]:
|
||||
73
backend/employees/ldap/utils/image_utils.py
Normal file
73
backend/employees/ldap/utils/image_utils.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Утилиты для обработки изображений (аватары).
|
||||
|
||||
Функции для нормализации и конвертации аватаров пользователей в формат,
|
||||
совместимый с Active Directory.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None # type: ignore
|
||||
|
||||
|
||||
def normalize_avatar_to_jpeg(
|
||||
data: bytes, *, size_px: int = 120, max_kb: int = 100
|
||||
) -> bytes:
|
||||
"""Нормализует аватар в JPEG нужного размера и лимита.
|
||||
|
||||
Args:
|
||||
data (bytes): Входной образ (PNG/JPEG/...).
|
||||
size_px (int): Квадратный размер целевого изображения (по умолчанию 120).
|
||||
max_kb (int): Максимальный размер файла в килобайтах
|
||||
(ограничение AD ~100 KB).
|
||||
|
||||
Returns:
|
||||
bytes: JPEG-данные, соответствующие ограничениям.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если Pillow недоступен или не удалось обработать
|
||||
изображение.
|
||||
ValueError: Если вход пустой.
|
||||
"""
|
||||
if not data:
|
||||
raise ValueError("avatar: пустые данные")
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
"Pillow не установлен. Установите 'Pillow' для обработки аватаров."
|
||||
)
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = img.convert("RGB")
|
||||
img = img.resize((size_px, size_px))
|
||||
|
||||
# бинарный поиск по качеству для соблюдения max_kb
|
||||
lo, hi = 50, 95
|
||||
best = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=mid, optimize=True)
|
||||
kb = len(buf.getvalue()) // 1024
|
||||
if kb <= max_kb:
|
||||
best = buf.getvalue()
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
|
||||
if best is None:
|
||||
# минимально возможное
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=50, optimize=True)
|
||||
best = buf.getvalue()
|
||||
|
||||
return best
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"normalize_avatar_to_jpeg",
|
||||
]
|
||||
@ -1,15 +1,28 @@
|
||||
# backend\employees\ldap\utils.py
|
||||
from __future__ import annotations
|
||||
"""LDAP-специфичные утилиты.
|
||||
|
||||
Функции для работы с LDAP-атрибутами, поиска, парсинга значений и работы с группами.
|
||||
"""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ldap3 import ALL_ATTRIBUTES, BASE, SUBTREE, Connection
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from PIL import Image
|
||||
|
||||
from .config import DISABLED_FLAG
|
||||
from ..config import DISABLED_FLAG
|
||||
|
||||
|
||||
def _first(val: Any) -> Any:
|
||||
"""Возвращает первый элемент для list/tuple, иначе исходное значение.
|
||||
|
||||
Args:
|
||||
val (Any): Значение (может быть list, tuple или скаляр).
|
||||
|
||||
Returns:
|
||||
Any: Первый элемент или сам объект.
|
||||
"""
|
||||
if isinstance(val, (list, tuple)):
|
||||
return val[0] if val else None
|
||||
return val
|
||||
|
||||
|
||||
def _uac_is_active(uac_value: Any) -> bool:
|
||||
@ -42,7 +55,17 @@ def _uac_is_active(uac_value: Any) -> bool:
|
||||
def _paged_search(
|
||||
conn: Connection, base: str, flt: str, *, page_size: int = 500
|
||||
) -> list:
|
||||
"""Постраничный поиск с возвратом всех entries."""
|
||||
"""Постраничный поиск с возвратом всех entries.
|
||||
|
||||
Args:
|
||||
conn (Connection): LDAP-соединение.
|
||||
base (str): Базовый DN для поиска.
|
||||
flt (str): LDAP-фильтр.
|
||||
page_size (int): Размер страницы (по умолчанию 500).
|
||||
|
||||
Returns:
|
||||
list: Список всех найденных entries.
|
||||
"""
|
||||
results: list = []
|
||||
cookie = None
|
||||
while True:
|
||||
@ -66,172 +89,113 @@ def _paged_search(
|
||||
return results
|
||||
|
||||
|
||||
# --- DN parsing -------------------------------------------------------
|
||||
|
||||
|
||||
def extract_department_from_dn(
|
||||
dn: str, *, departments_anchor: str = "OU=Departments"
|
||||
) -> Optional[str]:
|
||||
"""Возвращает имя отдела из DN при контейнерной модели.
|
||||
|
||||
Ищет сегмент OU=Departments и берёт OU слева от него как название отдела.
|
||||
def get_attr_str(attrs: Dict[str, Any], key: str, default: str = "") -> str:
|
||||
"""Строковое значение атрибута (учитывает list/bytes), trimmed.
|
||||
|
||||
Args:
|
||||
dn (str): Distinguished Name пользователя.
|
||||
departments_anchor (str): Маркер корневого OU отделов.
|
||||
attrs (Dict[str, Any]): Словарь атрибутов LDAP.
|
||||
key (str): Ключ атрибута.
|
||||
default (str): Значение по умолчанию.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Имя отдела или None, если пользователь не под OU=Departments.
|
||||
|
||||
Raises:
|
||||
TypeError: Если dn не строка.
|
||||
ValueError: Если dn пустой.
|
||||
str: Строковое значение атрибута или default.
|
||||
"""
|
||||
if not isinstance(dn, str):
|
||||
raise TypeError("dn должен быть строкой")
|
||||
dn = dn.strip()
|
||||
if not dn:
|
||||
raise ValueError("пустой DN")
|
||||
|
||||
parts: Sequence[str] = [p.strip() for p in dn.split(",") if p.strip()]
|
||||
# точное совпадение полного сегмента
|
||||
try:
|
||||
idx = parts.index(departments_anchor)
|
||||
except ValueError:
|
||||
# fallback: искать просто 'OU=Departments' без хвоста
|
||||
val = _first(attrs.get(key))
|
||||
if val is None:
|
||||
return default
|
||||
if isinstance(val, bytes):
|
||||
try:
|
||||
idx = next(
|
||||
i for i, p in enumerate(parts) if p.strip().lower() == "ou=departments"
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
val = val.decode("utf-8")
|
||||
except Exception:
|
||||
val = val.decode("latin-1", "ignore")
|
||||
return str(val).strip()
|
||||
|
||||
dept_idx = idx - 1
|
||||
if dept_idx < 0 or not parts[dept_idx].startswith("OU="):
|
||||
|
||||
def get_guid_str(attrs: Dict[str, Any]) -> Optional[str]:
|
||||
"""objectGUID → UUID-строка (учитывает bytes/list/str).
|
||||
|
||||
Args:
|
||||
attrs (Dict[str, Any]): Словарь атрибутов LDAP.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Строковое представление GUID или None.
|
||||
"""
|
||||
raw = _first(attrs.get("objectGUID"))
|
||||
if raw is None:
|
||||
return None
|
||||
value = parts[dept_idx][3:].strip()
|
||||
return value or None
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
return str(uuid.UUID(bytes_le=raw))
|
||||
except Exception:
|
||||
return None
|
||||
return str(raw)
|
||||
|
||||
|
||||
# --- Avatar normalization --------------------------------------------
|
||||
def _ldap_pick_phone(attrs: Dict[str, Any]) -> Optional[str]:
|
||||
"""Извлекает «лучший» телефон из LDAP-атрибутов.
|
||||
|
||||
|
||||
def normalize_avatar_to_jpeg(
|
||||
data: bytes, *, size_px: int = 120, max_kb: int = 100
|
||||
) -> bytes:
|
||||
"""Нормализует аватар в JPEG нужного размера и лимита.
|
||||
Ищет по стандартным полям AD: mobile, telephoneNumber (в таком порядке).
|
||||
|
||||
Args:
|
||||
data (bytes): Входной образ (PNG/JPEG/...).
|
||||
size_px (int): Квадратный размер целевого изображения.
|
||||
max_kb (int): Максимальный размер файла в килобайтах (ограничение AD ~100 KB).
|
||||
attrs (Dict[str, Any]): Словарь атрибутов записи LDAP
|
||||
(как `entry_attributes_as_dict`).
|
||||
|
||||
Returns:
|
||||
bytes: JPEG-данные, соответствующие ограничениям.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если Pillow недоступен или не удалось обработать изображение.
|
||||
ValueError: Если вход пустой.
|
||||
Optional[str]: Сырая строка телефона или None, если ничего не найдено.
|
||||
"""
|
||||
if not data:
|
||||
raise ValueError("avatar: пустые данные")
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
"Pillow не установлен. Установите 'Pillow' для обработки аватаров."
|
||||
)
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = img.convert("RGB")
|
||||
img = img.resize((size_px, size_px))
|
||||
# бинарный поиск по качеству для соблюдения max_kb
|
||||
lo, hi = 50, 95
|
||||
best = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=mid, optimize=True)
|
||||
kb = len(buf.getvalue()) // 1024
|
||||
if kb <= max_kb:
|
||||
best = buf.getvalue()
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
if best is None:
|
||||
# минимально возможное
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=50, optimize=True)
|
||||
best = buf.getvalue()
|
||||
return best
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc
|
||||
for key in ("mobile", "telephoneNumber"):
|
||||
v = attrs.get(key)
|
||||
if isinstance(v, list):
|
||||
v = v[0] if v else None
|
||||
if v:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
# --- Login/UPN generation --------------------------------------------
|
||||
def group_type(scope: str, security_enabled: bool) -> int:
|
||||
"""Возвращает числовой тип группы AD с учётом флага безопасности.
|
||||
|
||||
Args:
|
||||
scope (str): Область группы ('global', 'domain_local', 'universal').
|
||||
security_enabled (bool): Является ли группа группой безопасности.
|
||||
|
||||
Returns:
|
||||
int: Числовое значение для атрибута groupType.
|
||||
"""
|
||||
SCOPE = {
|
||||
"global": 0x00000002,
|
||||
"domain_local": 0x00000004,
|
||||
"universal": 0x00000008,
|
||||
}
|
||||
val = SCOPE.get(scope, 0x00000002)
|
||||
val |= 0x80000000 if security_enabled else 0
|
||||
if val & 0x80000000:
|
||||
return val - (1 << 32)
|
||||
return val
|
||||
|
||||
|
||||
def cn_candidates(pretty: str, safe: str) -> List[str]:
|
||||
"""Формирует список возможных CN: красивый + постфиксы, затем безопасный + постфиксы.
|
||||
|
||||
Args:
|
||||
pretty (str): "Красивый" вариант CN (например, с кириллицей).
|
||||
safe (str): "Безопасный" вариант CN (только ASCII).
|
||||
|
||||
Returns:
|
||||
List[str]: Список кандидатов CN в порядке приоритета.
|
||||
"""
|
||||
seq: List[str] = [pretty] + [f"{pretty} {i}" for i in range(2, 30)]
|
||||
seq += [safe] + [f"{safe} {i}" for i in range(2, 30)]
|
||||
return seq
|
||||
|
||||
|
||||
# --- Login/UPN generation (используется в directory_service и sync_service) ---
|
||||
|
||||
_RU2LAT = {
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "kh",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "shch",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
}
|
||||
_RESERVED = {"admin", "administrator", "guest", "krbtgt"}
|
||||
|
||||
|
||||
def translit_to_ascii(text: str) -> str:
|
||||
"""Транслитерирует строку в ascii: [a-z0-9], нижний регистр.
|
||||
|
||||
Args:
|
||||
text (str): Входная строка.
|
||||
|
||||
Returns:
|
||||
str: Нормализованная ASCII-строка.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("text должен быть строкой")
|
||||
s = (text or "").strip().lower()
|
||||
out: list[str] = []
|
||||
for ch in s:
|
||||
if "a" <= ch <= "z" or "0" <= ch <= "9":
|
||||
out.append(ch)
|
||||
continue
|
||||
if ch in (" ", ".", "_", "-"):
|
||||
continue
|
||||
if ch in _RU2LAT:
|
||||
out.append(_RU2LAT[ch])
|
||||
continue
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def make_base_login(first_name: str, last_name: str) -> str:
|
||||
"""Строит базовый логин: last + first_initial (ASCII).
|
||||
|
||||
@ -242,6 +206,8 @@ def make_base_login(first_name: str, last_name: str) -> str:
|
||||
Returns:
|
||||
str: Логин (может быть пустым, если нет данных).
|
||||
"""
|
||||
from .text_utils import translit_to_ascii
|
||||
|
||||
ln = translit_to_ascii(last_name)
|
||||
fi = translit_to_ascii(first_name[:1]) if first_name else ""
|
||||
return ln + fi
|
||||
@ -258,7 +224,12 @@ def fallback_login_from_email(
|
||||
|
||||
Returns:
|
||||
str: Логин.
|
||||
|
||||
Raises:
|
||||
TypeError: Если email не строка.
|
||||
"""
|
||||
from .text_utils import translit_to_ascii
|
||||
|
||||
if not isinstance(email, str):
|
||||
raise TypeError("email должен быть строкой")
|
||||
local = (email or "").split("@", 1)[0]
|
||||
@ -294,6 +265,8 @@ def ensure_unique_login(
|
||||
Raises:
|
||||
ValueError: Если base пуст или не найдено уникального значения.
|
||||
"""
|
||||
from .text_utils import translit_to_ascii
|
||||
|
||||
base = translit_to_ascii(base)
|
||||
if not base:
|
||||
raise ValueError("пустой base для логина")
|
||||
@ -345,125 +318,17 @@ def build_logins_for_user(
|
||||
return ensure_unique_login(fb, is_taken_sam, is_taken_upn, upn_suffix)
|
||||
|
||||
|
||||
# --- Attribute normalization (ldap3 returns lists/bytes) -------------
|
||||
|
||||
|
||||
def _first(val: Any) -> Any:
|
||||
"""Возвращает первый элемент для list/tuple, иначе исходное значение."""
|
||||
if isinstance(val, (list, tuple)):
|
||||
return val[0] if val else None
|
||||
return val
|
||||
|
||||
|
||||
def get_attr_str(attrs: Dict[str, Any], key: str, default: str = "") -> str:
|
||||
"""Строковое значение атрибута (учитывает list/bytes), trimmed."""
|
||||
val = _first(attrs.get(key))
|
||||
if val is None:
|
||||
return default
|
||||
if isinstance(val, bytes):
|
||||
try:
|
||||
val = val.decode("utf-8")
|
||||
except Exception:
|
||||
val = val.decode("latin-1", "ignore")
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def get_guid_str(attrs: Dict[str, Any]) -> Optional[str]:
|
||||
"""objectGUID → UUID-строка (учитывает bytes/list/str)."""
|
||||
raw = _first(attrs.get("objectGUID"))
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
return str(uuid.UUID(bytes_le=raw))
|
||||
except Exception:
|
||||
return None
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _ldap_pick_phone(attrs: Dict[str, Any]) -> Optional[str]:
|
||||
"""Извлекает «лучший» телефон из LDAP-атрибутов.
|
||||
|
||||
Ищет по стандартным полям AD: mobile, telephoneNumber (в таком порядке).
|
||||
|
||||
Args:
|
||||
attrs (Dict[str, Any]): Словарь атрибутов записи LDAP
|
||||
(как `entry_attributes_as_dict`).
|
||||
|
||||
Returns:
|
||||
Optional[str]: Сырая строка телефона или None, если ничего не найдено.
|
||||
"""
|
||||
for key in ("mobile", "telephoneNumber"):
|
||||
v = attrs.get(key)
|
||||
if isinstance(v, list):
|
||||
v = v[0] if v else None
|
||||
if v:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def esc_filter(value: Optional[str]) -> str:
|
||||
"""Экранирует строку для подстановки в LDAP-фильтры (RFC4515).
|
||||
|
||||
Args:
|
||||
value (Optional[str]): Исходная строка.
|
||||
|
||||
Returns:
|
||||
str: Экранированная строка.
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
try:
|
||||
return escape_filter_chars(s)
|
||||
except Exception:
|
||||
return (
|
||||
s.replace("\\", r"\5c")
|
||||
.replace("*", r"\2a")
|
||||
.replace("(", r"\28")
|
||||
.replace(")", r"\29")
|
||||
.replace("\x00", r"\00")
|
||||
)
|
||||
|
||||
|
||||
def esc_rdn(text: str) -> str:
|
||||
"""Экранирует RDN-компонент."""
|
||||
return (
|
||||
text.replace("\\", "\\\\")
|
||||
.replace(",", "\\,")
|
||||
.replace("+", "\\+")
|
||||
.replace(";", "\\;")
|
||||
.replace("=", "\\=")
|
||||
.replace("<", "\\<")
|
||||
.replace(">", "\\>")
|
||||
.replace("#", "\\#")
|
||||
.replace('"', '\\"')
|
||||
)
|
||||
|
||||
|
||||
def group_type(scope: str, security_enabled: bool) -> int:
|
||||
"""Возвращает числовой тип группы AD с учётом флага безопасности."""
|
||||
SCOPE = {
|
||||
"global": 0x00000002,
|
||||
"domain_local": 0x00000004,
|
||||
"universal": 0x00000008,
|
||||
}
|
||||
val = SCOPE.get(scope, 0x00000002)
|
||||
val |= 0x80000000 if security_enabled else 0
|
||||
if val & 0x80000000:
|
||||
return val - (1 << 32)
|
||||
return val
|
||||
|
||||
|
||||
def cn_candidates(pretty: str, safe: str) -> List[str]:
|
||||
"""Формирует список возможных CN: красивый + постфиксы, затем безопасный + постфиксы."""
|
||||
seq: List[str] = [pretty] + [f"{pretty} {i}" for i in range(2, 30)]
|
||||
seq += [safe] + [f"{safe} {i}" for i in range(2, 30)]
|
||||
return seq
|
||||
|
||||
|
||||
def rewrite_dn_suffix(dn: str, old_suffix: str, new_suffix: str) -> str:
|
||||
"""Заменяет хвост DN c old_suffix на new_suffix."""
|
||||
if not dn or not dn.lower().endswith(old_suffix.lower()):
|
||||
return dn
|
||||
return dn[: -len(old_suffix)] + new_suffix
|
||||
__all__ = [
|
||||
"_first",
|
||||
"_uac_is_active",
|
||||
"_paged_search",
|
||||
"get_attr_str",
|
||||
"get_guid_str",
|
||||
"_ldap_pick_phone",
|
||||
"group_type",
|
||||
"cn_candidates",
|
||||
"make_base_login",
|
||||
"fallback_login_from_email",
|
||||
"ensure_unique_login",
|
||||
"build_logins_for_user",
|
||||
]
|
||||
126
backend/employees/ldap/utils/text_utils.py
Normal file
126
backend/employees/ldap/utils/text_utils.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Утилиты для обработки текста и экранирования.
|
||||
|
||||
Функции для транслитерации, экранирования LDAP-значений и нормализации текста.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
|
||||
# Словарь для транслитерации русских символов в латиницу
|
||||
_RU2LAT = {
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "kh",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "shch",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
}
|
||||
|
||||
|
||||
def translit_to_ascii(text: str) -> str:
|
||||
"""Транслитерирует строку в ascii: [a-z0-9], нижний регистр.
|
||||
|
||||
Args:
|
||||
text (str): Входная строка.
|
||||
|
||||
Returns:
|
||||
str: Нормализованная ASCII-строка.
|
||||
|
||||
Raises:
|
||||
TypeError: Если text не строка.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("text должен быть строкой")
|
||||
s = (text or "").strip().lower()
|
||||
out: list[str] = []
|
||||
for ch in s:
|
||||
if "a" <= ch <= "z" or "0" <= ch <= "9":
|
||||
out.append(ch)
|
||||
continue
|
||||
if ch in (" ", ".", "_", "-"):
|
||||
continue
|
||||
if ch in _RU2LAT:
|
||||
out.append(_RU2LAT[ch])
|
||||
continue
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def esc_filter(value: Optional[str]) -> str:
|
||||
"""Экранирует строку для подстановки в LDAP-фильтры (RFC4515).
|
||||
|
||||
Args:
|
||||
value (Optional[str]): Исходная строка.
|
||||
|
||||
Returns:
|
||||
str: Экранированная строка.
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
try:
|
||||
return escape_filter_chars(s)
|
||||
except Exception:
|
||||
# Fallback если ldap3 не сработал
|
||||
return (
|
||||
s.replace("\\", r"\5c")
|
||||
.replace("*", r"\2a")
|
||||
.replace("(", r"\28")
|
||||
.replace(")", r"\29")
|
||||
.replace("\x00", r"\00")
|
||||
)
|
||||
|
||||
|
||||
def esc_rdn(text: str) -> str:
|
||||
"""Экранирует RDN-компонент Distinguished Name.
|
||||
|
||||
Args:
|
||||
text (str): RDN-значение (например, "John, Doe").
|
||||
|
||||
Returns:
|
||||
str: Экранированная строка (например, "John\\, Doe").
|
||||
"""
|
||||
return (
|
||||
text.replace("\\", "\\\\")
|
||||
.replace(",", "\\,")
|
||||
.replace("+", "\\+")
|
||||
.replace(";", "\\;")
|
||||
.replace("=", "\\=")
|
||||
.replace("<", "\\<")
|
||||
.replace(">", "\\>")
|
||||
.replace("#", "\\#")
|
||||
.replace('"', '\\"')
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"translit_to_ascii",
|
||||
"esc_filter",
|
||||
"esc_rdn",
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-21 04:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0031_department_ldap_group_dn_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeedepartment',
|
||||
name='uniq_employee_per_department',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='departmentpermission',
|
||||
name='code',
|
||||
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела')], max_length=64, unique=True),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeedepartment',
|
||||
constraint=models.UniqueConstraint(fields=('employee_id',), name='uniq_department_per_employee'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-21 05:06
|
||||
|
||||
import django.db.models.constraints
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0032_remove_employeedepartment_uniq_employee_per_department_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeedepartment',
|
||||
name='uniq_department_per_employee',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeedepartment',
|
||||
constraint=models.UniqueConstraint(deferrable=django.db.models.constraints.Deferrable['DEFERRED'], fields=('department_id', 'employee_id'), name='uniq_employee_per_department'),
|
||||
),
|
||||
]
|
||||
@ -8,7 +8,6 @@ from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from simple_history.models import HistoricalRecords
|
||||
from django.db.models import Q # для условий в уникальных ограничениях
|
||||
|
||||
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES, DeptPerm
|
||||
|
||||
@ -230,13 +229,14 @@ class Employee(AbstractUser):
|
||||
|
||||
@property
|
||||
def departments(self):
|
||||
"""Возвращает максимум один активный отдел (по новой логике)."""
|
||||
dept_ids = EmployeeDepartment.objects.filter(
|
||||
# Отделы, где сотрудник активен
|
||||
department_ids = EmployeeDepartment.objects.filter(
|
||||
employee=self, is_active=True
|
||||
).values_list("department_id", flat=True)[:1]
|
||||
).values_list("department_id", flat=True)
|
||||
# Плюс отделы, где сотрудник начальник
|
||||
head_ids = Department.objects.filter(head=self).values_list("id", flat=True)
|
||||
return Department.objects.filter(
|
||||
Q(id__in=dept_ids) | Q(id__in=head_ids)
|
||||
models.Q(id__in=department_ids) | models.Q(id__in=head_ids)
|
||||
).distinct()
|
||||
|
||||
@property
|
||||
@ -343,29 +343,16 @@ class Department(models.Model):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# --- Обновляем принадлежность текущего руководителя ---
|
||||
if self.head_id:
|
||||
link = EmployeeDepartment.objects.filter(employee_id=self.head_id).first()
|
||||
if link and link.department_id != self.pk:
|
||||
# Переназначаем отдел
|
||||
EmployeeDepartment.objects.filter(pk=link.pk).update(
|
||||
department_id=self.pk,
|
||||
is_active=True,
|
||||
date_to=None,
|
||||
)
|
||||
if not link.date_from:
|
||||
EmployeeDepartment.objects.filter(pk=link.pk).update(
|
||||
date_from=timezone.now().date()
|
||||
)
|
||||
elif not link:
|
||||
EmployeeDepartment.objects.create(
|
||||
try:
|
||||
link, created = EmployeeDepartment.objects.get_or_create(
|
||||
employee_id=self.head_id,
|
||||
department_id=self.pk,
|
||||
is_active=True,
|
||||
date_from=timezone.now().date(),
|
||||
defaults={
|
||||
"is_active": True,
|
||||
"date_from": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Обновляем статус если нужно
|
||||
updates = {}
|
||||
if not link.is_active:
|
||||
updates["is_active"] = True
|
||||
@ -373,12 +360,78 @@ class Department(models.Model):
|
||||
updates["date_from"] = timezone.now().date()
|
||||
if updates:
|
||||
EmployeeDepartment.objects.filter(pk=link.pk).update(**updates)
|
||||
except IntegrityError:
|
||||
# На случай гонки
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee_id=self.head_id, department_id=self.pk
|
||||
).update(is_active=True, date_from=timezone.now().date())
|
||||
|
||||
# --- Обрабатываем бывшего руководителя ---
|
||||
if (not is_create) and head_changed and old_head_id and old_head_id != self.head_id:
|
||||
# Бывший руководитель остаётся в старом отделе (по требованию можно деактивировать)
|
||||
# Здесь ничего не делаем, так как запись уже существует и не должна меняться.
|
||||
pass
|
||||
if (not is_create) and head_changed and old_head_id:
|
||||
if self.head_id is None:
|
||||
# Руководителя сняли: деактивируем его принадлежность
|
||||
link, created = EmployeeDepartment.objects.get_or_create(
|
||||
employee_id=old_head_id,
|
||||
department_id=self.pk,
|
||||
defaults={
|
||||
"is_active": True,
|
||||
"date_from": timezone.now().date(),
|
||||
"date_to": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee_id=old_head_id, department_id=self.pk
|
||||
).update(is_active=True, date_to=timezone.now().date())
|
||||
else:
|
||||
# Смена A -> B: по текущей логике бывший остаётся сотрудником отдела (активным)
|
||||
# Если хотите наоборот — поменяйте is_active=True на False и добавьте date_to.
|
||||
EmployeeDepartment.objects.get_or_create(
|
||||
employee_id=old_head_id,
|
||||
department_id=self.pk,
|
||||
defaults={
|
||||
"is_active": True,
|
||||
"date_from": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def active_employees(self):
|
||||
"""
|
||||
Все активные сотрудники отдела (через EmployeeDepartment),
|
||||
включая руководителя, если он есть и активен.
|
||||
"""
|
||||
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
|
||||
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
|
||||
|
||||
if self.head and self.head.is_actually_active and self.head not in employees:
|
||||
employees.append(self.head)
|
||||
|
||||
return employees
|
||||
|
||||
@property
|
||||
def new_employees(self):
|
||||
"""
|
||||
Новые сотрудники отдела за последний месяц (по дате присоединения).
|
||||
"""
|
||||
month_ago = timezone.now() - timezone.timedelta(days=30)
|
||||
qs = self.employeedepartment.filter(
|
||||
is_active=True, date_from__gte=month_ago
|
||||
).select_related("employee")
|
||||
|
||||
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
|
||||
|
||||
# Добавляем руководителя, если он недавно назначен и активен
|
||||
if (
|
||||
self.head
|
||||
and self.head_appointed_at
|
||||
and self.head_appointed_at >= month_ago
|
||||
and self.head.is_actually_active
|
||||
and self.head not in employees
|
||||
):
|
||||
employees.append(self.head)
|
||||
|
||||
return employees
|
||||
|
||||
|
||||
class DepartmentPermission(models.Model):
|
||||
@ -440,8 +493,9 @@ class EmployeeDepartment(DateRangeMixin, models.Model):
|
||||
verbose_name_plural = "Принадлежности к отделам"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["employee_id"],
|
||||
name="uniq_department_per_employee",
|
||||
fields=["department_id", "employee_id"],
|
||||
name="uniq_employee_per_department",
|
||||
deferrable=models.Deferrable.DEFERRED,
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
@ -449,15 +503,6 @@ class EmployeeDepartment(DateRangeMixin, models.Model):
|
||||
models.Index(fields=["employee_id", "department_id"]),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
# Запрещаем более одной активной принадлежности (и вообще единственную запись по сотруднику)
|
||||
existing = EmployeeDepartment.objects.filter(employee_id=self.employee_id)
|
||||
if self.pk:
|
||||
existing = existing.exclude(pk=self.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError({"employee": "Сотрудник уже состоит в другом отделе"})
|
||||
|
||||
def __str__(self):
|
||||
role_name = self.role.name if self.role else "сотрудник"
|
||||
return f"{self.employee} в {self.department} ({role_name})"
|
||||
|
||||
@ -11,7 +11,7 @@ from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from employees.ldap.utils import esc_filter
|
||||
from employees.ldap.utils.text_utils import esc_filter
|
||||
from employees.models import Department, Employee, EmployeeDepartment
|
||||
from employees.utils import _normalize_phone
|
||||
from ldap3 import SUBTREE, Connection, Server
|
||||
|
||||
@ -1,400 +0,0 @@
|
||||
// Проверяет наличие подписантов
|
||||
function checkSubscribes() {
|
||||
let subscr = Подписанты || [], officFaces = Официальные_лица || [];
|
||||
for (let i = 0; i < subscr.length; i++) {
|
||||
if (subscr[i]['Подписанты.Должность'] || subscr[i]['Подписанты.Подпись']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < officFaces.length; i++) {
|
||||
if (officFaces[i]['Официальные лица.ФИО'] || officFaces[i]['Официальные лица.Позиция'] || officFaces[i]['Официальные лица.Комментарий']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Возвращает учетные характеристики ("КодПартии" из Генератор.ТабличныеДанные.ИнфПолСтр, строкой)
|
||||
function get_nom_characteristics(info, charact) {
|
||||
var result = '',
|
||||
val,
|
||||
table = info;
|
||||
table.forEach(function(item) {
|
||||
if (item['Генератор.ТабличныеДанные.ИнфПол.Имя'].startsWith('КодПартии')) {
|
||||
val = item['Генератор.ТабличныеДанные.ИнфПол.Значение'] || '';
|
||||
result += ЭкранироватьHTML(val) + ' ';
|
||||
}
|
||||
});
|
||||
return result ? (', ' + result) : (charact ? (', ' + charact) : '');
|
||||
}
|
||||
// Возвращает примечание (Примечание из Генератор.ТабличныеДанные.ИнфПолСтр, строкой)
|
||||
function get_nom_note(info) {
|
||||
var result = '';
|
||||
var table = info;
|
||||
var temp = '';
|
||||
table.forEach(function(item) {
|
||||
if (item['Генератор.ТабличныеДанные.ИнфПол.Имя'] === 'Примечание') {
|
||||
temp = item['Генератор.ТабличныеДанные.ИнфПол.Значение'];
|
||||
result += '\n' + ЭкранироватьHTML(temp.toString());
|
||||
}
|
||||
});
|
||||
return result ? result : '';
|
||||
}
|
||||
// Возвращает краткий состав комплекта (СоставКомплекта из Генератор.ТабличныеДанные.ИнфПолСтр, строкой)
|
||||
function get_short_composition(info) {
|
||||
var table = info;
|
||||
var short_composition;
|
||||
var result = '';
|
||||
table.forEach(function(item) {
|
||||
if (item['Генератор.ТабличныеДанные.ИнфПол.Имя'] === 'СоставКомплекта') {
|
||||
short_composition = ЭкранироватьHTML(item['Генератор.ТабличныеДанные.ИнфПол.Значение'].toString());
|
||||
result = '\n' + short_composition;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// Возвращает состав комплекта (СоставКомплектаПодробный из Генератор.ТабличныеДанные.ИнфПолСтр, вёрстка)
|
||||
function get_full_composition(info, rnpt, date1, date2, flag1096) {
|
||||
var flagRnpt = rnpt;
|
||||
var dateDoc = new Date(date1);
|
||||
var dateStart = new Date(date2);
|
||||
var table = info;
|
||||
var full_composition = '';
|
||||
var nomenclature;
|
||||
var result = '';
|
||||
var result_rnpt = '';
|
||||
var row_number = 0;
|
||||
for (var i = 0; i < table.length; i++) {
|
||||
if (table[i]['Генератор.ТабличныеДанные.ИнфПол.Имя'] === 'СоставКомплектаПодробный') {
|
||||
full_composition = ЭкранироватьHTML(table[i]['Генератор.ТабличныеДанные.ИнфПол.Значение'].toString());
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
if (full_composition) {
|
||||
full_composition = full_composition.split('##');
|
||||
let rowspan = full_composition.length;
|
||||
if (flagRnpt && (dateDoc > dateStart)) {
|
||||
for (let i = 0; i < rowspan; i++) {
|
||||
nomenclature = full_composition[i].split('#');
|
||||
row_number++;
|
||||
result += '<tr><td colspan=3 cstyle="EX2">' + row_number + '</td><td colspan=15 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') + '</td></tr>';
|
||||
if (i === 0) {
|
||||
if (flag1096) {
|
||||
result_rnpt += '<tr><td colspan=3 cstyle="EX2">' + row_number + '</td><td colspan=15 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') +
|
||||
'</td><td rowspan=' + rowspan + ' colspan=9></td><td rowspan=' + rowspan + ' colspan=31></td></tr>';
|
||||
} else {
|
||||
result_rnpt += '<tr><td colspan=3 cstyle="EX2">' + row_number + '</td><td colspan=15 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') +
|
||||
'</td><td rowspan=' + rowspan + ' colspan=12></td><td rowspan=' + rowspan + ' colspan=28></td></tr>';
|
||||
}
|
||||
} else {
|
||||
result_rnpt += '<tr><td colspan=3 cstyle="EX2">' + row_number + '</td><td colspan=15 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') + '</td></tr>';
|
||||
}
|
||||
}
|
||||
} else if (dateDoc > dateStart) {
|
||||
for (let i = 0; i < rowspan; i++) {
|
||||
nomenclature = full_composition[i].split('#');
|
||||
row_number++;
|
||||
result += '<tr><td colspan=4 cstyle="EX2">' + row_number + '</td><td colspan=42 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') + '</td></tr>';
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < rowspan; i++) {
|
||||
nomenclature = full_composition[i].split('#');
|
||||
row_number++;
|
||||
result += '<tr><td colspan=4 cstyle="EX2">' + row_number + '</td><td colspan=46 cstyle="EX1">' + ЭкранироватьHTML(nomenclature[0].toString()) +
|
||||
'</td><td colspan=8></td><td colspan=6 cstyle="EX3">' + ((nomenclature[1]) ? ФорматЧисло(nomenclature[1]) : '-') + '</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
return {full_composition : result, full_composition_rnpt : result_rnpt, length : full_composition.length};
|
||||
}
|
||||
// Возвращает сумму НДС по формату
|
||||
function get_vat_sum(vat_sum, tax_rate, wo_vat) {
|
||||
var result = '';
|
||||
if (vat_sum) {
|
||||
result = ФорматЧисло(parseFloat(vat_sum).toFixed(2));
|
||||
} else if (tax_rate === 'НДС исчисляется налоговым агентом') {
|
||||
result = '-';
|
||||
} else if (wo_vat === 'без НДС') {
|
||||
result = 'без НДС';
|
||||
} else {
|
||||
result = '0.00';
|
||||
}
|
||||
return result;
|
||||
|
||||
}
|
||||
// Возвращает сумму по формату
|
||||
function get_sum(sum, tax_rate) {
|
||||
var result = '0.00';
|
||||
if (sum) {
|
||||
result = ФорматЧисло(parseFloat(sum).toFixed(2));
|
||||
} else if (tax_rate === 'НДС исчисляется налоговым агентом') {
|
||||
result = '-';
|
||||
}
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
// Построение таблицы номенклатуры
|
||||
function mainTable(tableInfo) {
|
||||
var nom_array = tableInfo,
|
||||
body = '',
|
||||
total_price_sum_wo_vat = 0,
|
||||
total_vat_sum = 0,
|
||||
total_vat_sum_flag = true,
|
||||
total_sum = 0,
|
||||
row_number,name,charactTov,code,info,
|
||||
unit_code,
|
||||
unit,
|
||||
count,
|
||||
product_type,
|
||||
price_wo_vat,
|
||||
price_sum_wo_vat,
|
||||
excise,
|
||||
wo_excise,
|
||||
tax_rate,
|
||||
wo_vat,
|
||||
vat_sum,
|
||||
sum,
|
||||
country_code,
|
||||
country,
|
||||
declaration,
|
||||
full_composition,
|
||||
full_composition_rnpt,
|
||||
full_composition_length,
|
||||
rowspan,
|
||||
rowspanAddFields,
|
||||
flagRnpt = false,
|
||||
dateDoc = new Date(Генератор.Дата),
|
||||
dateStart = new Date('2021-06-30'),
|
||||
dateRes1096 = new Date('2024-09-30'),
|
||||
flagRes1096 = false,
|
||||
mainTableStr = '',
|
||||
totalTableStr = '',
|
||||
rowspanRnpt = '',
|
||||
rowspanRnptComp = '',
|
||||
footColspan2 = '7',
|
||||
startTable = '<table sstyles="EX1:EX12">',
|
||||
rnptResult = '',
|
||||
header = '',
|
||||
viewAddFields = Отобразить_дополнительные_поля,
|
||||
addFields = false,
|
||||
excepAddFields = ['КодПартии','Примечание','СоставКомплекта','СоставКомплектаПодробный','КодВидТов'];
|
||||
|
||||
// Флаг для внесения правок в формы по постановлению 1096 для документов от 01.10.24
|
||||
if (dateDoc.setHours(0, 0, 0, 0) > dateRes1096.setHours(0, 0, 0, 0)) {
|
||||
flagRes1096 = true;
|
||||
}
|
||||
// Проверка на содержание прослеживаемых товаров в документе
|
||||
for (let i = 0; i < nom_array.length; i++) {
|
||||
if (nom_array[i]['Генератор.ТабличныеДанные.RnptData'].length) {
|
||||
flagRnpt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
startTable += '<col width=8>'.repeat(152);
|
||||
// Для документов от 01.07.2021 с прослеживаемыми товарами добавляется 3 столбца
|
||||
if (flagRnpt && (dateDoc > dateStart)) {
|
||||
//Формирование шапки таблицы
|
||||
if (flagRes1096) {
|
||||
header += startTable + '<tr cstyle="EX11"><td rowspan=4 colspan=13 cstyle="EX12">Код товара/\nработ,услуг</td><td rowspan=4 colspan=4>№\nп/п</td><td rowspan=4 colspan=18>Наименование товара\n(описание выполненных работ, оказанных услуг),\nимущественного права</td><td rowspan=4 colspan=5>Код\nвида\nтова-\nра</td><td colspan=11 rowspan=1>Единица\nизмерения</td><td rowspan=4 colspan=6>Коли-\nчество\n(объем)</td><td rowspan=4 colspan=7>Цена, (тариф) за единицу измере-\nния</td>';
|
||||
header += '<td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав без налога - всего</td><td rowspan=4 colspan=5>В том числе сумма акциза</td><td rowspan=4 colspan=5>Нало-\nговая\nставка</td><td rowspan=4 colspan=8>Сумма налога,предъявля-\nемая покупа-\nтелю</td><td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав с налогом - всего</td><td colspan=12 rowspan=2>Страна происхождения товара</td>';
|
||||
header += '<td rowspan=4 colspan=9>Регистрационный номер декларации на товары или регистрационный номер партии товара, подлежащего прослеживаемости</td><td colspan=10 rowspan=3>Количественная единица измерения товара, используемая в целях осуществления прослеживаемости</td><td rowspan=4 colspan=11>Количество товара, подлежащего прослеживаемости, в количественной единице измерения товара, используемой в целях осуществления прослеживаемости</td>';
|
||||
header += '<td rowspan=4 colspan=10>Стоимость товара, подлежащего прослеживаемости, без налога на добавленную стоимость, в рублях</td></tr>';
|
||||
header += '<tr cstyle="EX11"><td rowspan=3 colspan=3>код</td><td rowspan=3 colspan=8>условное\nобозначе-\nние (националь-\nное)</td></tr><tr cstyle="EX11"><td rowspan=2 colspan=5>Цифро-\nвой код</td><td rowspan=2 colspan=7>Краткое наимено-\nвание</td></tr><tr cstyle="EX11"><td rowspan=1 colspan=3>код</td><td rowspan=1 colspan=7>условное\nобозначение</td></tr>';
|
||||
header += '<tr cstyle="EX4"><td colspan=13 cstyle="EX6">А</td><td colspan=4>1</td><td colspan=18>1а</td><td colspan=5>1б</td><td colspan=3>2</td><td colspan=8>2а</td><td colspan=6>3</td><td colspan=7>4</td><td colspan=9>5</td><td colspan=5>6</td><td colspan=5>7</td><td colspan=8>8</td><td colspan=9>9</td><td colspan=5>10</td><td colspan=7>10а</td><td colspan=9>11</td><td colspan=3>12</td><td colspan=7>12а</td><td colspan=11>13</td><td colspan=10>14</td></tr>';
|
||||
} else {
|
||||
header += startTable + '<tr cstyle="EX4"><td rowspan=4 colspan=13 cstyle="EX6">Код товара/\nработ,услуг</td><td rowspan=4 colspan=4>№\nп/п</td><td rowspan=4 colspan=18>Наименование товара\n(описание выполненных работ, оказанных услуг),\nимущественного права</td><td rowspan=4 colspan=5>Код\nвида\nтова-\nра</td><td colspan=11 rowspan=1>Единица\nизмерения</td><td rowspan=4 colspan=6>Коли-\nчество\n(объем)</td><td rowspan=4 colspan=7>Цена, (тариф) за единицу измере-\nния</td>';
|
||||
header += '<td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав без налога - всего</td><td rowspan=4 colspan=5>В том числе сумма акциза</td><td rowspan=4 colspan=5>Нало-\nговая\nставка</td><td rowspan=4 colspan=8>Сумма налога,предъявля-\nемая покупа-\nтелю</td><td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав с налогом - всего</td><td colspan=12 rowspan=2>Страна происхождения товара</td>';
|
||||
header += '<td rowspan=4 colspan=12>Регистрационный номер декларации на товары или регистрационный номер партии товара, подлежащего прослеживаемости</td><td colspan=13 rowspan=3>Количественная единица измерения товара, используемая в целях осуществления прослеживаемости</td><td rowspan=4 colspan=15>Количество товара, подлежащего прослеживаемости, в количественной единице измерения товара, используемой в целях осуществления прослеживаемости</td></tr>';
|
||||
header += '<tr cstyle="EX4"><td rowspan=3 colspan=3>код</td><td rowspan=3 colspan=8>условное\nобозначе-\nние (националь-\nное)</td></tr><tr cstyle="EX4"><td rowspan=2 colspan=5>Цифро-\nвой код</td><td rowspan=2 colspan=7>Краткое наимено-\nвание</td></tr><tr cstyle="EX4"><td rowspan=1 colspan=4>код</td><td rowspan=1 colspan=9>условное\nобозначение</td></tr>';
|
||||
header += '<tr cstyle="EX4"><td colspan=13 cstyle="EX6">А</td><td colspan=4>1</td><td colspan=18>1а</td><td colspan=5>1б</td><td colspan=3>2</td><td colspan=8>2а</td><td colspan=6>3</td><td colspan=7>4</td><td colspan=9>5</td><td colspan=5>6</td><td colspan=5>7</td><td colspan=8>8</td><td colspan=9>9</td><td colspan=5>10</td><td colspan=7>10а</td><td colspan=12>11</td><td colspan=4>12</td><td colspan=9>12а</td><td colspan=15>13</td></tr>';
|
||||
}
|
||||
footColspan2 = '51';
|
||||
} else if (dateDoc > dateStart) {
|
||||
//Формирование шапки таблицы
|
||||
header += startTable + '<tr cstyle="EX4"><td rowspan=4 colspan=13 cstyle="EX6">Код товара/\nработ,услуг</td><td rowspan=4 colspan=4>№\nп/п</td><td rowspan=4 colspan=46>Наименование товара\n(описание выполненных работ, оказанных услуг),\nимущественного права</td><td rowspan=4 colspan=5>Код\nвида\nтова-\nра</td><td colspan=11 rowspan=1>Единица\nизмерения</td><td rowspan=4 colspan=6>Коли-\nчество\n(объем)</td><td rowspan=4 colspan=7>Цена, (тариф) за единицу измере-\nния</td>';
|
||||
header += '<td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав без налога - всего</td><td rowspan=4 colspan=5>В том числе сумма акциза</td><td rowspan=4 colspan=5>Нало-\nговая\nставка</td><td rowspan=4 colspan=8>Сумма налога,предъявля-\nемая покупа-\nтелю</td><td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав с налогом - всего</td><td colspan=12 rowspan=2>Страна происхождения товара</td>';
|
||||
header += '<td rowspan=4 colspan=12>Регистрационный номер декларации на товары или регистрационный номер партии товара, подлежащего прослеживаемости</td></tr><tr cstyle="EX4"><td rowspan=3 colspan=3>код</td><td rowspan=3 colspan=8>условное\nобозначе-\nние (националь-\nное)</td></tr><tr cstyle="EX4"><td rowspan=2 colspan=5>Цифро-\nвой код</td><td rowspan=2 colspan=7>Краткое наимено-\nвание</td></tr>';
|
||||
header += '<tr cstyle="EX4"></tr><tr cstyle="EX4"><td colspan=13 cstyle="EX6">А</td><td colspan=4>1</td><td colspan=46>1а</td><td colspan=5>1б</td><td colspan=3>2</td><td colspan=8>2а</td><td colspan=6>3</td><td colspan=7>4</td><td colspan=9>5</td><td colspan=5>6</td><td colspan=5>7</td><td colspan=8>8</td><td colspan=9>9</td><td colspan=5>10</td><td colspan=7>10а</td><td colspan=12>11</td></tr>';
|
||||
footColspan2 = '79';
|
||||
} else {
|
||||
//Формирование шапки таблицы
|
||||
header += startTable + '<tr cstyle="EX4"><td rowspan=4 colspan=4>№\nп/п</td><td rowspan=4 colspan=9 cstyle="EX6">Код товара/\nработ,услуг</td><td rowspan=4 colspan=50>Наименование товара\n(описание выполненных работ, оказанных услуг),\nимущественного права</td><td rowspan=4 colspan=5>Код\nвида\nтова-\nра</td><td colspan=11 rowspan=1>Единица\nизмерения</td><td rowspan=4 colspan=6>Коли-\nчество\n(объем)</td><td rowspan=4 colspan=7>Цена, (тариф) за единицу измере-\nния</td>';
|
||||
header += '<td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав без налога - всего</td><td rowspan=4 colspan=5>В том числе сумма акциза</td><td rowspan=4 colspan=5>Нало-\nговая\nставка</td><td rowspan=4 colspan=8>Сумма налога,предъявля-\nемая покупа-\nтелю</td><td rowspan=4 colspan=9>Стоимость товаров (работ, услуг), имуществен-\nных прав с налогом - всего</td><td colspan=12 rowspan=2>Страна происхождения товара</td>';
|
||||
header += '<td rowspan=4 colspan=12>Регистрационный номер декларации на товары или регистрационный номер партии товара, подлежащего прослеживаемости</td></tr><tr cstyle="EX4"><td rowspan=3 colspan=3>код</td><td rowspan=3 colspan=8>условное\nобозначе-\nние (националь-\nное)</td></tr><tr cstyle="EX4"><td rowspan=2 colspan=5>Цифро-\nвой код</td><td rowspan=2 colspan=7>Краткое наимено-\nвание</td></tr>';
|
||||
header += '<tr cstyle="EX4"></tr><tr cstyle="EX4"><td colspan=4 cstyle="EX4">А</td><td cstyle="EX6" colspan=9>Б</td><td colspan=50>1</td><td colspan=5>1а</td><td colspan=3>2</td><td colspan=8>2а</td><td colspan=6>3</td><td colspan=7>4</td><td colspan=9>5</td><td colspan=5>6</td><td colspan=5>7</td><td colspan=8>8</td><td colspan=9>9</td><td colspan=5>10</td><td colspan=7>10а</td><td colspan=12>11</td></tr>';
|
||||
footColspan2 = '79';
|
||||
}
|
||||
|
||||
//Формирование основной части таблицы
|
||||
nom_array.forEach(function(nom) {
|
||||
//инициализация полей
|
||||
row_number = nom['Генератор.ТабличныеДанные.ПорНомер'];
|
||||
name = nom['Генератор.ТабличныеДанные.Наименование'];
|
||||
charactTov = nom['Генератор.ТабличныеДанные.КодПартии'] || '';
|
||||
code = nom['Генератор.ТабличныеДанные.НомНомер'] || '-';
|
||||
info = nom['Генератор.ТабличныеДанные.ИнфПол'];
|
||||
unit_code = nom['Генератор.ТабличныеДанные.ЕдКод'] || '-';
|
||||
unit = nom['Генератор.ТабличныеДанные.ЕдИзм'] || '-';
|
||||
if (unit.indexOf('^') >= 0) {
|
||||
unit = unit.replace(/\^(\d+)/g, '$1').replace(/\^/g, '');
|
||||
}
|
||||
count = nom['Генератор.ТабличныеДанные.Количество'];
|
||||
if (count) {
|
||||
count = (count < 1) ? ЭкспоненциальнаяВЧислоСтрокой(count) : ФорматЧисло(count);
|
||||
} else {
|
||||
count = '-';
|
||||
}
|
||||
product_type = nom['Генератор.ТабличныеДанные.КодВидТов'] ? ЭкранироватьHTML(nom['Генератор.ТабличныеДанные.КодВидТов'].toString()) : '-';
|
||||
price_wo_vat = nom['Генератор.ТабличныеДанные.ЦенаБезНДС'];
|
||||
price_sum_wo_vat = nom['Генератор.ТабличныеДанные.СуммаЦенБезНДС'];
|
||||
excise = nom['Генератор.ТабличныеДанные.Акциз'];
|
||||
wo_excise = nom['Генератор.ТабличныеДанные.БезАкциз'];
|
||||
tax_rate = nom['Генератор.ТабличныеДанные.НДСЗначение'];
|
||||
wo_vat = nom['Генератор.ТабличныеДанные.БезНДС'];
|
||||
vat_sum = nom['Генератор.ТабличныеДанные.СуммаНДС'];
|
||||
sum = nom['Генератор.ТабличныеДанные.СуммаЦен'];
|
||||
country_code = nom['Генератор.ТабличныеДанные.КодСтраныПроизводства'] || '-';
|
||||
country_code = (country_code === '643') ? '-' : country_code;
|
||||
country = nom['Генератор.ТабличныеДанные.СтранаПроизводства'];
|
||||
country = (country === 'РОССИЯ') ? '-' : country;
|
||||
declaration = nom['Генератор.ТабличныеДанные.ГТД'] || '-';
|
||||
full_composition = get_full_composition(info, flagRnpt, dateDoc, dateStart, flagRes1096);
|
||||
full_composition_length = full_composition.length;
|
||||
full_composition_rnpt = full_composition.full_composition_rnpt;
|
||||
full_composition = full_composition.full_composition;
|
||||
rowspan = (full_composition) ? ('rowspan=' + (full_composition_length + 1)) : ' ';
|
||||
|
||||
let addFieldsArray = [],
|
||||
addFiledsStr = '';
|
||||
addFields = false;
|
||||
if (viewAddFields) {
|
||||
for (let i = 0; i < info.length; i++) {
|
||||
if (!excepAddFields.includes(info[i]['Генератор.ТабличныеДанные.ИнфПол.Имя'])) {
|
||||
addFieldsArray.push(info[i]['Генератор.ТабличныеДанные.ИнфПол.Имя'] + ': ' + ЭкранироватьHTML((info[i]['Генератор.ТабличныеДанные.ИнфПол.Значение'] || '').replace(/\n/g, ' ')));
|
||||
}
|
||||
}
|
||||
if (addFieldsArray.length) {
|
||||
addFields = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Для документов от 01.07.2021 с прослеживаемыми товарами добавляется 3 столбца
|
||||
rnptResult = '';
|
||||
mainTableStr = '';
|
||||
rowspanRnpt = '';
|
||||
rowspanRnptComp = '';
|
||||
if (flagRnpt && (dateDoc > dateStart)) {
|
||||
let rnptData = nom['Генератор.ТабличныеДанные.RnptData'];
|
||||
let rnptRowspan = 1;
|
||||
if (rnptData.length) {
|
||||
let rnptRow01 = rnptData[0]['Генератор.ТабличныеДанные.RnptData.Rnpt'] ? rnptData[0]['Генератор.ТабличныеДанные.RnptData.Rnpt'] : '-',
|
||||
rnptRow02 = rnptData[0]['Генератор.ТабличныеДанные.RnptData.MuCode'] ? rnptData[0]['Генератор.ТабличныеДанные.RnptData.MuCode'] : '-',
|
||||
rnptRow03 = rnptData[0]['Генератор.ТабличныеДанные.RnptData.MuName'] ? rnptData[0]['Генератор.ТабличныеДанные.RnptData.MuName'] : '-',
|
||||
rnptRow04 = rnptData[0]['Генератор.ТабличныеДанные.RnptData.RnptQty'] ? rnptData[0]['Генератор.ТабличныеДанные.RnptData.RnptQty'] : '-';
|
||||
if (flagRes1096) {
|
||||
let rnptRow05 = rnptData[0]['Генератор.ТабличныеДанные.RnptData.SumCostWoVat'] ? parseFloat(rnptData[0]['Генератор.ТабличныеДанные.RnptData.SumCostWoVat']).toFixed(2) : '0';
|
||||
mainTableStr = '<td colspan=9 cstyle="EX2">' + rnptRow01 + '</td><td colspan=3 cstyle="EX2">' + rnptRow02 + '</td><td colspan=7 cstyle="EX2">' + rnptRow03 + '</td><td colspan=11 cstyle="EX2">' + rnptRow04 + '</td><td colspan=10 cstyle="EX2">' + rnptRow05 + '</td>';
|
||||
} else {
|
||||
mainTableStr = '<td colspan=12 cstyle="EX2">' + rnptRow01 + '</td><td colspan=4 cstyle="EX2">' + rnptRow02 + '</td><td colspan=9 cstyle="EX2">' + rnptRow03 + '</td><td colspan=15 cstyle="EX2">' + rnptRow04 + '</td>';
|
||||
}
|
||||
rnptRowspan = rnptData.length;
|
||||
|
||||
for (let i = 1; i < rnptData.length; i++) {
|
||||
let rnptRow1 = rnptData[i]['Генератор.ТабличныеДанные.RnptData.Rnpt'] ? rnptData[i]['Генератор.ТабличныеДанные.RnptData.Rnpt'] : '-',
|
||||
rnptRow2 = rnptData[i]['Генератор.ТабличныеДанные.RnptData.MuCode'] ? rnptData[i]['Генератор.ТабличныеДанные.RnptData.MuCode'] : '-',
|
||||
rnptRow3 = rnptData[i]['Генератор.ТабличныеДанные.RnptData.MuName'] ? rnptData[i]['Генератор.ТабличныеДанные.RnptData.MuName'] : '-',
|
||||
rnptRow4 = rnptData[i]['Генератор.ТабличныеДанные.RnptData.RnptQty'] ? rnptData[i]['Генератор.ТабличныеДанные.RnptData.RnptQty'] : '-';
|
||||
if (flagRes1096) {
|
||||
let rnptRow5 = rnptData[i]['Генератор.ТабличныеДанные.RnptData.SumCostWoVat'] ? parseFloat(rnptData[i]['Генератор.ТабличныеДанные.RnptData.SumCostWoVat']).toFixed(2) : '0';
|
||||
rnptResult += '<tr><td colspan=9 cstyle="EX2">' + rnptRow1 + '</td><td colspan=3 cstyle="EX2">' + rnptRow2 + '</td><td colspan=7 cstyle="EX2">' + rnptRow3 + '</td><td colspan=11 cstyle="EX2">' + rnptRow4 + '</td><td colspan=10 cstyle="EX2">' + rnptRow5 + '</td>';
|
||||
} else {
|
||||
rnptResult += '<tr><td colspan=12 cstyle="EX2">' + rnptRow1 + '</td><td colspan=4 cstyle="EX2">' + rnptRow2 + '</td><td colspan=9 cstyle="EX2">' + rnptRow3 + '</td><td colspan=15 cstyle="EX2">' + rnptRow4 + '</td>';
|
||||
}
|
||||
rnptResult += '</tr>';
|
||||
}
|
||||
} else {
|
||||
if (flagRes1096) {
|
||||
mainTableStr = '<td colspan=9 cstyle="EX2">' + (declaration ? declaration : '-') + '</td><td colspan=3 cstyle="EX2">-</td><td colspan=7 cstyle="EX2">-</td><td colspan=11 cstyle="EX2">-</td><td colspan=10 cstyle="EX2">-</td>';
|
||||
} else {
|
||||
mainTableStr = '<td colspan=12 cstyle="EX2">' + (declaration ? declaration : '-') + '</td><td colspan=4 cstyle="EX2">-</td><td colspan=9 cstyle="EX2">-</td><td colspan=15 cstyle="EX2">-</td>';
|
||||
}
|
||||
}
|
||||
rowspanRnpt = 'rowspan=' + rnptRowspan;
|
||||
rowspanRnptComp = 'rowspan=' + (rnptRowspan + full_composition_length);
|
||||
if (flagRes1096) {
|
||||
totalTableStr = '<td colspan=3 cstyle="EX8"></td><td colspan=7 cstyle="EX8"></td><td colspan=11 cstyle="EX8"></td><td colspan=10 cstyle="EX8"></td>';
|
||||
} else {
|
||||
totalTableStr = '<td colspan=4 cstyle="EX8"></td><td colspan=9 cstyle="EX8"></td><td colspan=15 cstyle="EX8"></td>';
|
||||
}
|
||||
|
||||
// расчет допполей
|
||||
if (!addFields) {
|
||||
rowspanAddFields = rowspanRnptComp;
|
||||
} else {
|
||||
rowspanAddFields = 'rowspan=' + (rnptRowspan + full_composition_length + 1);
|
||||
addFiledsStr = '<tr><td colspan=135>' + addFieldsArray.join(', ') + '</td></tr>';
|
||||
}
|
||||
|
||||
//Формирование тела таблицы в документе с прослеживаемостью
|
||||
body += '<tr><td ' + rowspanAddFields + ' colspan=13 cstyle="EX7">' + ЭкранироватьHTML(code.toString()) + '</td><td ' + rowspanAddFields + ' colspan=4 cstyle="EX2">' + row_number + '</td><td ' + rowspanRnpt + ' colspan=18 cstyle="EX1">' + ЭкранироватьHTML(name.toString()) + get_nom_characteristics(info, charactTov) + get_nom_note(info) + get_short_composition(info) + '</td>';
|
||||
body += '<td ' + rowspanRnptComp + ' colspan=5 cstyle="EX2">' + product_type + '</td><td ' + rowspanRnptComp + ' colspan=3 cstyle="EX2">' + unit_code + '</td><td ' + rowspanRnpt + ' colspan=8 cstyle="EX2">' + unit + '</td><td ' + rowspanRnpt + ' colspan=6 cstyle="EX3">' + count + '</td><td ' + rowspanRnptComp + ' colspan=7 cstyle="EX3">' + (price_wo_vat ? ФорматЧисло(parseFloat(price_wo_vat).toFixed(2)) : '-') + '</td>';
|
||||
body += '<td ' + rowspanRnptComp + ' colspan=9 cstyle="EX3">' + (price_sum_wo_vat ? ФорматЧисло(parseFloat(price_sum_wo_vat).toFixed(2)) : '-') + '</td><td ' + rowspanRnptComp + ' colspan=5 cstyle="EX2">' + ((wo_excise === 'без акциза') ? 'без акциза' : ((excise) ? ФорматЧисло(parseFloat(excise).toFixed(2)) : '0.00')) + '</td><td ' + rowspanRnptComp + ' colspan=5 cstyle="EX2">' + (tax_rate ? tax_rate : '-') + '</td>';
|
||||
body += '<td ' + rowspanRnptComp + ' colspan=8 cstyle="EX3">' + get_vat_sum (vat_sum, tax_rate, wo_vat) + '</td><td ' + rowspanRnptComp + ' colspan=9 cstyle="EX3">' + get_sum(sum, tax_rate) + '</td><td ' + rowspanRnptComp + ' colspan=5 cstyle="EX2">' + (country_code ? country_code : '-') + '</td><td ' + rowspanRnptComp + ' colspan=7 cstyle="EX2">' + (country ? country : '-') + '</td>' + mainTableStr + '</tr>' + rnptResult + full_composition_rnpt + addFiledsStr;
|
||||
} else if (dateDoc > dateStart) {
|
||||
|
||||
// расчет допполей
|
||||
if (!addFields) {
|
||||
rowspanAddFields = rowspan;
|
||||
} else {
|
||||
rowspanAddFields = (full_composition) ? ('rowspan=' + (full_composition_length + 2)) : 'rowspan=2';
|
||||
addFiledsStr = '<tr><td colspan=135>' + addFieldsArray.join(', ') + '</td></tr>';
|
||||
}
|
||||
|
||||
//Формирование тела таблицы в документе без прослеживаемости
|
||||
body += '<tr><td ' + rowspanAddFields + ' colspan=13 cstyle="EX7">' + ЭкранироватьHTML(code.toString()) + '</td><td ' + rowspanAddFields + ' colspan=4 cstyle="EX2">' + row_number + '</td><td colspan=46 cstyle="EX1">' + ЭкранироватьHTML(name.toString()) + get_nom_characteristics(info, charactTov) + get_nom_note(info) + get_short_composition(info) + '</td><td ' + rowspan + ' colspan=5 cstyle="EX2">' + product_type + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=3 cstyle="EX2">' + unit_code + '</td><td colspan=8 cstyle="EX2">' + unit + '</td><td colspan=6 cstyle="EX3">' + count + '</td><td ' + rowspan + ' colspan=7 cstyle="EX3">' + (price_wo_vat ? ФорматЧисло(parseFloat(price_wo_vat).toFixed(2)) : '-') + '</td><td ' + rowspan + ' colspan=9 cstyle="EX3">' + (price_sum_wo_vat ? ФорматЧисло(parseFloat(price_sum_wo_vat).toFixed(2)) : '-') + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=5 cstyle="EX2">' + ((wo_excise === 'без акциза') ? 'без акциза' : ((excise) ? ФорматЧисло(parseFloat(excise).toFixed(2)) : '0.00')) + '</td><td ' + rowspan + ' colspan=5 cstyle="EX2">' + (tax_rate ? tax_rate : '-') + '</td><td ' + rowspan + ' colspan=8 cstyle="EX3">' + get_vat_sum (vat_sum, tax_rate, wo_vat) + '</td><td ' + rowspan + ' colspan=9 cstyle="EX3">' + get_sum(sum, tax_rate) + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=5 cstyle="EX2">' + (country_code ? country_code : '-') + '</td><td ' + rowspan + ' colspan=7 cstyle="EX2">' + (country ? country : '-') + '</td><td ' + rowspan + ' colspan=12>' + (declaration ? declaration : '-') + '</td></tr>' + full_composition + addFiledsStr;
|
||||
} else {
|
||||
|
||||
// расчет допполей
|
||||
if (!addFields) {
|
||||
rowspanAddFields = rowspan;
|
||||
} else {
|
||||
rowspanAddFields = (full_composition) ? ('rowspan=' + (full_composition_length + 2)) : 'rowspan=2';
|
||||
addFiledsStr = '<tr><td colspan=139>' + addFieldsArray.join(', ') + '</td></tr>';
|
||||
}
|
||||
|
||||
//Формирование тела таблицы в документе без прослеживаемости
|
||||
body += '<tr><td ' + rowspanAddFields + ' colspan=4 cstyle="EX2">' + row_number + '</td><td ' + rowspanAddFields + ' colspan=9 cstyle="EX7">' + ЭкранироватьHTML(code.toString()) + '</td><td colspan=50 cstyle="EX1">' + ЭкранироватьHTML(name.toString()) + get_nom_characteristics(info, charactTov) + get_nom_note(info) + get_short_composition(info) + '</td><td ' + rowspan + ' colspan=5 cstyle="EX2">' + product_type + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=3 cstyle="EX2">' + unit_code + '</td><td colspan=8 cstyle="EX2">' + unit + '</td><td colspan=6 cstyle="EX3">' + count + '</td><td ' + rowspan + ' colspan=7 cstyle="EX3">' + (price_wo_vat ? ФорматЧисло(parseFloat(price_wo_vat).toFixed(2)) : '-') + '</td><td ' + rowspan + ' colspan=9 cstyle="EX3">' + (price_sum_wo_vat ? ФорматЧисло(parseFloat(price_sum_wo_vat).toFixed(2)) : '-') + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=5 cstyle="EX2">' + ((wo_excise === 'без акциза') ? 'без акциза' : ((excise) ? ФорматЧисло(parseFloat(excise).toFixed(2)) : '0.00')) + '</td><td ' + rowspan + ' colspan=5 cstyle="EX2">' + (tax_rate ? tax_rate : '-') + '</td><td ' + rowspan + ' colspan=8 cstyle="EX3">' + get_vat_sum (vat_sum, tax_rate, wo_vat) + '</td><td ' + rowspan + ' colspan=9 cstyle="EX3">' + get_sum(sum, tax_rate) + '</td>';
|
||||
body += '<td ' + rowspan + ' colspan=5 cstyle="EX2">' + (country_code ? country_code : '-') + '</td><td ' + rowspan + ' colspan=7 cstyle="EX2">' + (country ? country : '-') + '</td><td ' + rowspan + ' colspan=12>' + (declaration ? declaration : '-') + '</td></tr>' + full_composition + addFiledsStr;
|
||||
}
|
||||
|
||||
//расчет итоговых параметров
|
||||
total_price_sum_wo_vat += price_sum_wo_vat ? parseFloat(price_sum_wo_vat) : 0;
|
||||
total_vat_sum += vat_sum ? parseFloat(vat_sum) : 0;
|
||||
if (total_vat_sum_flag) {
|
||||
total_vat_sum_flag = wo_vat === 'без НДС';
|
||||
}
|
||||
total_sum += sum ? parseFloat(sum) : 0;
|
||||
});
|
||||
|
||||
//Формирование подвала таблицы
|
||||
body += '<tr><td colspan=13 cstyle="EX9"></td><td colspan=' + footColspan2 + ' cstyle="EX5">Всего к оплате</td><td colspan=9 cstyle="EX3">' + ФорматЧисло(total_price_sum_wo_vat.toFixed(2)) + '</td><td colspan=10 cstyle="EX2">X</td><td colspan=8 cstyle="EX3">' + (total_vat_sum_flag ? 'без НДС' : ФорматЧисло(total_vat_sum.toFixed(2))) + '</td>';
|
||||
if (flagRes1096) {
|
||||
body += '<td colspan=9 cstyle="EX3">' + ФорматЧисло(total_sum.toFixed(2)) + '</td><td colspan=5 cstyle="EX8"></td><td colspan=7 cstyle="EX8"></td><td colspan=9 cstyle="EX8"></td>' + totalTableStr + '</tr>';
|
||||
} else {
|
||||
body += '<td colspan=9 cstyle="EX3">' + ФорматЧисло(total_sum.toFixed(2)) + '</td><td colspan=5 cstyle="EX8"></td><td colspan=7 cstyle="EX8"></td><td colspan=12 cstyle="EX8"></td>' + totalTableStr + '</tr>';
|
||||
}
|
||||
|
||||
return (header + body + '</table>');
|
||||
}
|
||||
56
backend/render_test_page.py
Normal file
56
backend/render_test_page.py
Normal file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Создаёт HTML файл с рендером страницы отдела для проверки.
|
||||
"""
|
||||
import os
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from django.template import loader, Context
|
||||
from django.contrib.auth import get_user_model
|
||||
from employees.models import Department
|
||||
from django.test import RequestFactory
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Получаем отдел и пользователя
|
||||
dept = Department.objects.filter(id=16).first()
|
||||
if not dept:
|
||||
dept = Department.objects.first()
|
||||
|
||||
user = User.objects.filter(is_superuser=True).first()
|
||||
|
||||
if not dept or not user:
|
||||
print("Нет данных для рендеринга")
|
||||
exit(1)
|
||||
|
||||
# Создаём request
|
||||
factory = RequestFactory()
|
||||
request = factory.get(f'/employees/departments/{dept.id}/')
|
||||
request.user = user
|
||||
|
||||
# Получаем view context
|
||||
from employees.views_front import department_detail
|
||||
response = department_detail(request, pk=dept.id)
|
||||
|
||||
# Сохраняем HTML
|
||||
output_file = 'department_page_render.html'
|
||||
with open(output_file, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
print(f"✓ Страница сохранена в {output_file}")
|
||||
print(f" Размер: {len(response.content)} байт")
|
||||
|
||||
# Анализ содержимого
|
||||
html = response.content.decode('utf-8')
|
||||
print(f"\nАнализ:")
|
||||
print(f" <style> тегов: {html.count('<style>')}")
|
||||
print(f" <script> тегов: {html.count('<script>')}")
|
||||
print(f" .team-wheel: {'✓' if '.team-wheel' in html else '✗'}")
|
||||
print(f" .calendar-wrap: {'✓' if '.calendar-wrap' in html else '✗'}")
|
||||
print(f" initTeamWheel: {'✓' if 'initTeamWheel' in html else '✗'}")
|
||||
print(f" FullCalendar: {'✓' if 'FullCalendar' in html else '✗'}")
|
||||
print(f" dropdown-toggle: {'✓' if 'dropdown-toggle' in html else '✗'}")
|
||||
print(f" Bootstrap JS: {'✓' if 'bootstrap.bundle.min.js' in html else '✗'}")
|
||||
@ -171,7 +171,7 @@ class RequestsView(LoginRequiredMixin, TemplateView):
|
||||
ValueError: При невалидных входных данных для конкретных экшенов.
|
||||
"""
|
||||
|
||||
template_name = "requests/request_list.html"
|
||||
template_name = "requests_app/request_list_full.html"
|
||||
|
||||
# ---------- GET ----------
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Подтверждение выхода</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- если используешь bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="container py-5">
|
||||
<div class="card shadow-sm mx-auto" style="max-width:480px;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-3">Выйти из аккаунта?</h1>
|
||||
<p class="text-secondary mb-4">Вы уверены, что хотите завершить сеанс?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% else %}
|
||||
<input type="hidden" name="next" value="{% url 'auth_front:login' %}">
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-danger">Выйти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,7 +0,0 @@
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h1>Сброс пароля</h1>
|
||||
<p>Укажите e-mail вашей учетной записи.</p>
|
||||
{{ form.email.label_tag }} {{ form.email }}
|
||||
<button type="submit">Отправить ссылку</button>
|
||||
</form>
|
||||
@ -24,7 +24,12 @@
|
||||
crossorigin="anonymous">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<!-- Базовые стили приложения (+ feed) -->
|
||||
<!-- CSS Components -->
|
||||
<link rel="stylesheet" href="{% static 'css/variables.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
|
||||
<!-- Базовые стили приложения -->
|
||||
<style>
|
||||
:root{
|
||||
--sidebar-w: 190px;
|
||||
@ -110,55 +115,9 @@
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18);
|
||||
}
|
||||
|
||||
/* ===== feed styles (карточки ленты) ===== */
|
||||
.feed-header{ display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
|
||||
.feed-header .title{ font-weight:800; letter-spacing:.2px; margin:0; }
|
||||
/* Feed styles теперь в static/css/components/feed-cards.css */
|
||||
|
||||
.feed-stream{ display:flex; flex-direction:column; gap:0; }
|
||||
|
||||
.feed-card{
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||||
border-radius: var(--feed-radius);
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: var(--feed-shadow);
|
||||
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
|
||||
overflow:hidden;
|
||||
}
|
||||
.feed-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
|
||||
|
||||
.feed-hd{ display:flex; align-items:center; gap:.75rem; padding:.9rem 1rem .5rem 1rem; }
|
||||
.feed-ava{
|
||||
width:42px; height:42px; border-radius:9999px; overflow:hidden;
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
background: var(--bs-tertiary-bg);
|
||||
display:grid; place-items:center; flex:0 0 auto;
|
||||
}
|
||||
.feed-ava img{ width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.feed-meta{ line-height:1.2; }
|
||||
.feed-author{ font-weight:700; color: var(--bs-body-color); }
|
||||
.feed-sub{ font-size:.82rem; color: var(--bs-secondary-color); }
|
||||
|
||||
.feed-pin{
|
||||
margin-left:auto; font-size:.75rem;
|
||||
padding:.2rem .5rem; border-radius:999px;
|
||||
background:#fff3cd; color:#664d03; border:1px solid #ffe69c;
|
||||
}
|
||||
[data-bs-theme="dark"] .feed-pin{ background:#3b2e04; color:#ffda6a; border-color:#4f3f0a; }
|
||||
|
||||
.feed-body{ padding:.25rem .75rem .5rem .75rem; position:relative; }
|
||||
.feed-title {
|
||||
font: 700 1.3rem/1.5 var(--bs-body-font-family);
|
||||
margin: 0.25rem 0 0.35rem;
|
||||
color: #4B4B4B; /* Альтернатива: rgba(0,0,0,.8) */
|
||||
}
|
||||
|
||||
.feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); }
|
||||
.feed-img img{ display:block; width:100%; height:auto; aspect-ratio:16/9; object-fit:cover; }
|
||||
|
||||
.feed-actions{
|
||||
display:flex; align-items:center; gap:.5rem; padding:0rem 1rem .9rem 1rem;
|
||||
border-top:1px solid var(--bs-border-color); position:relative; z-index:2;
|
||||
}
|
||||
/* Дополнительные стили для кнопок */
|
||||
.btn-ghost{
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1391
backend/templates/employees/_employee_edit.html.backup
Normal file
1391
backend/templates/employees/_employee_edit.html.backup
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,64 @@
|
||||
{# ===== Заголовок ===== #}
|
||||
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class="bi-diagram-3 fs-2 text-primary"></i>
|
||||
<h2 class="title ios-title mb-0">{{ dept.name }}</h2>
|
||||
{% if user.is_staff or user.is_superuser or can_manage or dept.head.id == user.id %}
|
||||
<div class="ms-2 header-icon-group d-inline-flex align-items-center gap-2">
|
||||
|
||||
{# Добавить участника #}
|
||||
<button type="button"
|
||||
class="btn-icon btn-icon--success"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#addMemberModal-{{ dept.id }}"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Добавить участника"
|
||||
aria-label="Добавить участника">
|
||||
<i class="bi-person-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
{# Назначить роль #}
|
||||
{% if roles %}
|
||||
{% if user.is_superuser or user.is_staff or can_assign_roles or dept.head.id == user.id %}
|
||||
<button type="button"
|
||||
class="btn-icon btn-icon--primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#setRoleModal-{{ dept.id }}"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Назначить роль"
|
||||
aria-label="Назначить роль">
|
||||
<i class="bi-award" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Удалить участника #}
|
||||
<button type="button"
|
||||
class="btn-icon btn-icon--danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#removeMemberModal-{{ dept.id }}"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Удалить участника"
|
||||
aria-label="Удалить участника">
|
||||
<i class="bi-person-dash" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
{% url 'feed:post_create' as post_create_url %}
|
||||
{% if user.is_authenticated and user.id in members_exclude_ids %}
|
||||
<a href="{{ post_create_url }}?department={{ dept_id }}&next={{ request.get_full_path|urlencode }}"
|
||||
class="btn btn-primary">
|
||||
<i class="bi-plus-circle me-1"></i> Создать публикацию
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Две колонки как в employee_detail: слева основное, справа сайдбар ===== #}
|
||||
<div class="row g-4 align-items-start">
|
||||
|
||||
{# ===== ЛЕВАЯ КОЛОНКА (основная, как в профиле): команда + инфо ===== #}
|
||||
40
backend/templates/employees/components/department_info.html
Normal file
40
backend/templates/employees/components/department_info.html
Normal file
@ -0,0 +1,40 @@
|
||||
{# --- Инфо-блок отдела (под командой) --- #}
|
||||
{% if dept.description or dept.head %}
|
||||
<section class="feed-card mb-3">
|
||||
<div class="feed-body position-relative pb-4 pe-3 pt-0">
|
||||
{% if dept.description %}
|
||||
<div class="text-break" style="white-space: pre-line; overflow-wrap:anywhere;">
|
||||
{{ dept.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if dept.head %}
|
||||
<div class="small mt-2">
|
||||
<span class="text-secondary">Руководитель:</span>
|
||||
{% url 'employees:employee_detail' dept.head.id as head_url %}
|
||||
{% if head_url %}
|
||||
<a class="text-decoration-none" href="{{ head_url }}">
|
||||
{{ dept.head.last_name }}{% if dept.head.first_name %} {{ dept.head.first_name }}{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ dept.head.last_name }}{% if dept.head.first_name %} {{ dept.head.first_name }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff or dept.head.id == user.id or can_manage or can_assign_roles or can_change_head %}
|
||||
<button
|
||||
type="button"
|
||||
class="position-absolute p-0 bg-transparent border-0 text-secondary"
|
||||
style="right:.5rem; bottom:.5rem;"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#deptEditWrap-{{ dept.id }}"
|
||||
aria-controls="deptEditWrap-{{ dept.id }}"
|
||||
title="Настройки отдела"
|
||||
aria-label="Показать/скрыть настройки отдела">
|
||||
<i class="bi-gear fs-5"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
197
backend/templates/employees/components/department_modals.html
Normal file
197
backend/templates/employees/components/department_modals.html
Normal file
@ -0,0 +1,197 @@
|
||||
{# ---------- МОДАЛ «Удалить участника» ---------- #}
|
||||
{% if user.is_staff or user.is_superuser or can_manage or dept.head.id == user.id %}
|
||||
<div class="modal fade" id="removeMemberModal-{{ dept.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi-person-dash me-2"></i>Удалить участника</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'employees:department_remove_member' dept.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="employee_id" id="removeMemberChosen-{{ dept.id }}">
|
||||
<input type="hidden" name="member_id" id="removeMemberLink-{{ dept.id }}">
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<input id="removeMemberSearch-{{ dept.id }}" type="search"
|
||||
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off">
|
||||
<div class="form-text">Отображаются участники этого отдела.</div>
|
||||
</div>
|
||||
|
||||
<div id="removeMemberResults-{{ dept.id }}"
|
||||
class="list-group list-group-flush border rounded">
|
||||
{% for li in links %}
|
||||
{% with emp=li.employee %}
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2"
|
||||
data-id="{{ emp.id }}"
|
||||
data-link-id="{{ li.id }}"
|
||||
data-name="{{ emp.display_name|default:emp.last_name }}"
|
||||
data-email="{{ emp.email|default:'' }}">
|
||||
<span class="feed-ava"
|
||||
style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="" style="width:100%;height:100%;object-fit:cover;">{% else %}<i class="bi-person"></i>{% endif %}
|
||||
</span>
|
||||
<span class="d-flex flex-column text-start">
|
||||
<span class="fw-semibold">{{ emp.display_name|default:emp.last_name }}</span>
|
||||
{% if emp.email %}<span class="text-muted small">{{ emp.email }}</span>{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<div class="list-group-item text-secondary small py-3">В отделе пока нет участников.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link" data-bs-dismiss="modal">
|
||||
<i class="bi-x-lg me-1"></i>Отмена
|
||||
</button>
|
||||
<button type="submit" id="removeMemberSubmit-{{ dept.id }}" class="btn btn-outline-danger" disabled>
|
||||
<i class="bi-person-dash me-1"></i>Удалить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ---------- МОДАЛ «Добавить участника» ---------- #}
|
||||
{% if user.is_staff or user.is_superuser or can_manage or dept.head.id == user.id %}
|
||||
{% with exclude_ids=members_exclude_ids %}
|
||||
<div class="modal fade" id="addMemberModal-{{ dept.id }}" tabindex="-1" aria-hidden="true"
|
||||
data-exclude="{% if exclude_ids %}{{ exclude_ids|join:',' }}{% endif %}">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi-person-plus me-2"></i>Добавить участника</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'employees:department_add_member' dept.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="employee_id" id="addMemberChosen-{{ dept.id }}">
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<input id="addMemberSearch-{{ dept.id }}" type="search"
|
||||
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off"
|
||||
data-add-member-q>
|
||||
<div class="form-text">Показываются только те, кто ещё не состоит в отделе.</div>
|
||||
</div>
|
||||
|
||||
<div id="addMemberResults-{{ dept.id }}" class="list-group list-group-flush border rounded"
|
||||
data-add-member-list>
|
||||
<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>
|
||||
</div>
|
||||
|
||||
<template id="addMemberItemTpl-{{ dept.id }}">
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2" data-id="">
|
||||
<span class="feed-ava" style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
<i class="bi-person"></i>
|
||||
</span>
|
||||
<span class="d-flex flex-column text-start">
|
||||
<span class="fw-semibold js-name"></span>
|
||||
<span class="text-muted small js-email"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link" data-bs-dismiss="modal">
|
||||
<i class="bi-x-lg me-1"></i>Отмена
|
||||
</button>
|
||||
<button type="submit" id="addMemberSubmit-{{ dept.id }}" class="btn btn-success"
|
||||
data-add-member-submit disabled>
|
||||
<i class="bi-person-plus me-1"></i>Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{# ---------- МОДАЛ «Назначить роль» ---------- #}
|
||||
{% if roles %}
|
||||
{% if user.is_superuser or user.is_staff or can_assign_roles or dept.head.id == user.id %}
|
||||
<div class="modal fade" id="setRoleModal-{{ dept.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi-award me-2"></i>Назначить роль</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
{# порядок аргументов: emp_id, dept_id #}
|
||||
<form method="post"
|
||||
action="{% url 'employees:set_member_role' 0 dept.id %}"
|
||||
data-action-template="{% url 'employees:set_member_role' 0 dept.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<input type="hidden" name="employee_id" id="setRoleChosen-{{ dept.id }}">
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="setRoleSearch-{{ dept.id }}" class="form-label">Участник</label>
|
||||
<input id="setRoleSearch-{{ dept.id }}" type="search"
|
||||
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off">
|
||||
<div id="setRoleResults-{{ dept.id }}"
|
||||
class="list-group list-group-flush border rounded mt-2" style="max-height:320px;overflow:auto;">
|
||||
{% for li in links %}
|
||||
{% with emp=li.employee %}
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2"
|
||||
data-id="{{ emp.id }}"
|
||||
data-name="{{ emp.display_name|default:emp.last_name }}"
|
||||
data-email="{{ emp.email|default:'' }}">
|
||||
<span class="feed-ava"
|
||||
style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="" style="width:100%;height:100%;object-fit:cover;">{% else %}<i class="bi-person"></i>{% endif %}
|
||||
</span>
|
||||
<span class="d-flex flex-column text-start">
|
||||
<span class="fw-semibold">{{ emp.display_name|default:emp.last_name }}</span>
|
||||
{% if emp.email %}<span class="text-muted small">{{ emp.email }}</span>{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<div class="list-group-item text-secondary small py-3">В отделе пока нет участников.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="setRoleSelect-{{ dept.id }}" class="form-label">Роль</label>
|
||||
<select name="role_id" id="setRoleSelect-{{ dept.id }}" class="form-select">
|
||||
<option value="">— Без роли —</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}">{{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Выберите участника слева и роль здесь.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link" data-bs-dismiss="modal">
|
||||
<i class="bi-x-lg me-1"></i>Отмена
|
||||
</button>
|
||||
<button type="submit" id="setRoleSubmit-{{ dept.id }}" class="btn btn-primary" disabled>
|
||||
<i class="bi-check2 me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
513
backend/templates/employees/components/department_scripts.html
Normal file
513
backend/templates/employees/components/department_scripts.html
Normal file
@ -0,0 +1,513 @@
|
||||
{# ==== JS: круг с автоскроллом/ручным управлением и индикатором ==== #}
|
||||
<script>
|
||||
(function(){
|
||||
const wheel = document.getElementById('teamWheel-{{ dept.id }}');
|
||||
if (!wheel) return;
|
||||
|
||||
const columns = wheel.querySelector('[data-columns]');
|
||||
const col1 = columns.querySelector('[data-col="1"]');
|
||||
const col2 = columns.querySelector('[data-col="2"]');
|
||||
const col3 = columns.querySelector('[data-col="3"]');
|
||||
const col4 = columns.querySelector('[data-col="4"]');
|
||||
|
||||
const focus = wheel.querySelector('[data-focus]');
|
||||
const fImg = wheel.querySelector('[data-focus-img]');
|
||||
const fFallback = wheel.querySelector('[data-focus-fallback]');
|
||||
|
||||
const wrapper = wheel.closest('.team-wrap');
|
||||
const hintBox = wrapper ? wrapper.querySelector('[data-hint]') : null;
|
||||
const details = wrapper ? wrapper.querySelector('[data-details]') : null;
|
||||
const dName = wrapper ? wrapper.querySelector('[data-details-name]') : null;
|
||||
const dRole = wrapper ? wrapper.querySelector('[data-details-role]') : null;
|
||||
const dMail = wrapper ? wrapper.querySelector('[data-details-email]') : null;
|
||||
|
||||
const arcProg = wheel.querySelector('.arc-prog');
|
||||
|
||||
// Источник данных
|
||||
const src = document.getElementById('teamData-{{ dept.id }}');
|
||||
const members = Array.from(src ? src.children : []).map(n => ({
|
||||
id: Number(n.getAttribute('data-id') || '0'),
|
||||
name: n.getAttribute('data-name') || '',
|
||||
role: n.getAttribute('data-role') || '',
|
||||
email: n.getAttribute('data-email') || '',
|
||||
avatar: n.getAttribute('data-avatar') || '',
|
||||
active: n.getAttribute('data-active') === '1'
|
||||
}));
|
||||
|
||||
// Если никого нет — показываем руководителя
|
||||
if (!members.length){
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
const headAva = "{{ dept.head.avatar|default:'' }}";
|
||||
const headName= "{% if dept.head %}{{ dept.head.display_name|default:dept.head.last_name }}{% else %}Руководитель{% endif %}";
|
||||
const headRole= "Руководитель отдела";
|
||||
const headMail= "{% if dept.head and dept.head.email %}{{ dept.head.email }}{% endif %}";
|
||||
|
||||
if (headAva){
|
||||
fImg.src = headAva; fImg.alt = headName; fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src'); fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
if (hintBox) hintBox.classList.add('d-none');
|
||||
if (details){
|
||||
details.classList.remove('d-none');
|
||||
dName && (dName.textContent = headName);
|
||||
dRole && (dRole.textContent = headRole);
|
||||
dMail && (dMail.textContent = headMail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сортировки для 4 колонок: во всех — весь список
|
||||
const byNameAsc = [...members].sort((a,b)=>a.name.localeCompare(b.name,'ru'));
|
||||
const byNameDesc = [...members].sort((a,b)=>b.name.localeCompare(a.name,'ru'));
|
||||
const byIdAsc = [...members].sort((a,b)=>a.id-b.id);
|
||||
const byRoleAsc = [...members].sort((a,b)=>{
|
||||
const ra = (a.role||'').toLowerCase(), rb = (b.role||'').toLowerCase();
|
||||
if (ra === rb) return a.name.localeCompare(b.name,'ru');
|
||||
return ra < rb ? -1 : 1;
|
||||
});
|
||||
|
||||
function makeItem(m){
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'wheel-item';
|
||||
btn.setAttribute('data-id', String(m.id));
|
||||
btn.setAttribute('data-name', m.name);
|
||||
btn.setAttribute('data-role', m.role || '');
|
||||
btn.setAttribute('data-email', m.email || '');
|
||||
btn.setAttribute('data-avatar', m.avatar || '');
|
||||
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'ava';
|
||||
if (m.avatar){
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = m.avatar;
|
||||
inner.appendChild(img);
|
||||
} else {
|
||||
const ico = document.createElement('i');
|
||||
ico.className = 'bi-person';
|
||||
inner.appendChild(ico);
|
||||
}
|
||||
btn.appendChild(inner);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function fillCol(root, arr){
|
||||
root.innerHTML = '';
|
||||
// Дублируем для «бесконечной» прокрутки
|
||||
for (let k=0;k<2;k++){
|
||||
arr.forEach(m => root.appendChild(makeItem(m)));
|
||||
}
|
||||
}
|
||||
fillCol(col1, byNameAsc);
|
||||
fillCol(col2, byNameDesc);
|
||||
fillCol(col3, byIdAsc);
|
||||
fillCol(col4, byRoleAsc);
|
||||
|
||||
// Виртуальный скролл
|
||||
let vScroll = 0;
|
||||
let dir = 1; // 1 -> вниз, -1 -> вверх
|
||||
const speed = 0.45; // px/frame
|
||||
let hover = false;
|
||||
let focused = false;
|
||||
let maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
|
||||
function applyScroll(){
|
||||
columns.style.transform = `translateY(${-vScroll}px)`;
|
||||
if (maxY <= 0){
|
||||
arcProg.style.strokeDashoffset = 1;
|
||||
} else {
|
||||
const p = vScroll / maxY; // 0..1
|
||||
arcProg.style.strokeDashoffset = (1 - p).toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function recalc(){
|
||||
maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
vScroll = Math.min(vScroll, maxY);
|
||||
applyScroll();
|
||||
}
|
||||
new ResizeObserver(recalc).observe(wheel);
|
||||
window.addEventListener('resize', recalc);
|
||||
|
||||
function loop(){
|
||||
if (!hover && !focused && maxY > 0){
|
||||
vScroll += dir * speed;
|
||||
if (vScroll >= maxY){
|
||||
vScroll = maxY;
|
||||
dir = -1; // на нижней границе едем вверх
|
||||
} else if (vScroll <= 0){
|
||||
vScroll = 0;
|
||||
dir = 1; // цикл завершается только на верхней точке
|
||||
}
|
||||
applyScroll();
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
applyScroll();
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
// Hover — пауза автоскролла, включаем ручной
|
||||
wheel.addEventListener('mouseenter', ()=>{ hover = true; });
|
||||
wheel.addEventListener('mouseleave', ()=>{ hover = false; });
|
||||
|
||||
// Ручной скролл колесом
|
||||
wheel.addEventListener('wheel', (e)=>{
|
||||
if (!focused){
|
||||
e.preventDefault();
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + e.deltaY));
|
||||
applyScroll();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Ручной скролл тачем
|
||||
let tStartY = null;
|
||||
wheel.addEventListener('touchstart', (e)=>{
|
||||
if (e.touches && e.touches.length){
|
||||
tStartY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
wheel.addEventListener('touchmove', (e)=>{
|
||||
if (tStartY != null && !focused){
|
||||
const y = e.touches[0].clientY;
|
||||
const dy = tStartY - y;
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + dy));
|
||||
tStartY = y;
|
||||
applyScroll();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
wheel.addEventListener('touchend', ()=>{ tStartY = null; });
|
||||
|
||||
// Фокус: клик по мини-аве
|
||||
columns.addEventListener('click', (e)=>{
|
||||
const btn = e.target.closest('.wheel-item');
|
||||
if (!btn) return;
|
||||
const ava = btn.getAttribute('data-avatar') || '';
|
||||
const name= btn.getAttribute('data-name') || 'Сотрудник';
|
||||
const role= btn.getAttribute('data-role') || '';
|
||||
const mail= btn.getAttribute('data-email') || '';
|
||||
|
||||
focused = true;
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
if (ava){
|
||||
fImg.src = ava; fImg.alt = name; fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src'); fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
hintBox && hintBox.classList.add('d-none');
|
||||
if (details){
|
||||
details.classList.remove('d-none');
|
||||
dName && (dName.textContent = name);
|
||||
dRole && (dRole.textContent = role);
|
||||
dMail && (dMail.textContent = mail);
|
||||
}
|
||||
});
|
||||
|
||||
// Выход из фокуса — клик по большому кругу
|
||||
focus.addEventListener('click', ()=>{
|
||||
focused = false;
|
||||
focus.classList.add('d-none');
|
||||
columns.classList.remove('d-none');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ===== Переключатель панели настроек (скролл к блоку при открытии) ===== #}
|
||||
<script>
|
||||
/* Smooth-scroll к «Настройкам отдела» при открытии на мобильных */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wrap = document.getElementById('deptEditWrap-{{ dept.id }}');
|
||||
if (!wrap) return;
|
||||
|
||||
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
|
||||
|
||||
function headerOffset(){
|
||||
const sticky = document.querySelector('.ios-header');
|
||||
return (sticky?.offsetHeight || 0) + 8;
|
||||
}
|
||||
function smoothScrollTo(el){
|
||||
const top = el.getBoundingClientRect().top + window.pageYOffset - headerOffset();
|
||||
try { window.scrollTo({ top, behavior: 'smooth' }); }
|
||||
catch { window.scrollTo(0, top); }
|
||||
}
|
||||
|
||||
wrap.addEventListener('shown.bs.collapse', () => {
|
||||
if (isMobile()) smoothScrollTo(wrap);
|
||||
});
|
||||
|
||||
if (wrap.classList.contains('show') && isMobile()) {
|
||||
setTimeout(() => smoothScrollTo(wrap), 50);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{# ===== Модал «Добавить участника»: AJAX-поиск ===== #}
|
||||
<script>
|
||||
{% url 'employees:employees_list' as employees_list_url %}
|
||||
(function(){
|
||||
const modal = document.getElementById('addMemberModal-{{ dept.id }}');
|
||||
if (!modal) return;
|
||||
|
||||
const form = modal.querySelector('form');
|
||||
const input = modal.querySelector('[data-add-member-q]');
|
||||
const listEl = modal.querySelector('[data-add-member-list]');
|
||||
const tpl = modal.querySelector('#addMemberItemTpl-{{ dept.id }}');
|
||||
const hidden = form ? form.querySelector('input[name="employee_id"]') : null;
|
||||
const submit = modal.querySelector('[data-add-member-submit]');
|
||||
if (!form || !input || !listEl || !tpl || !hidden || !submit) return;
|
||||
|
||||
const excludeAttr = (modal.getAttribute('data-exclude') || '').trim();
|
||||
const exclude = new Set(
|
||||
excludeAttr ? excludeAttr.split(',').map(x => parseInt(x, 10)).filter(n => !Number.isNaN(n)) : []
|
||||
);
|
||||
|
||||
function esc(s){ return String(s).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||||
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
|
||||
|
||||
let aborter = null;
|
||||
|
||||
async function searchEmployees(q){
|
||||
if (aborter) aborter.abort();
|
||||
aborter = new AbortController();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
format: 'json',
|
||||
q: q,
|
||||
limit: '20',
|
||||
exclude: Array.from(exclude).join(',')
|
||||
});
|
||||
|
||||
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
|
||||
credentials: 'same-origin',
|
||||
signal: aborter.signal
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json().catch(()=>({results:[]}));
|
||||
return Array.isArray(data) ? data : (data.results || []);
|
||||
}
|
||||
|
||||
function render(items){
|
||||
listEl.innerHTML = '';
|
||||
if (!items.length){
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'list-group-item text-muted small py-3';
|
||||
empty.textContent = 'Ничего не найдено';
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
items.forEach(e => {
|
||||
const node = tpl.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.id = e.id;
|
||||
const ava = node.querySelector('.feed-ava');
|
||||
if (ava){
|
||||
ava.innerHTML = e.avatar
|
||||
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
|
||||
: '<i class="bi-person"></i>';
|
||||
}
|
||||
const nameEl = node.querySelector('.js-name');
|
||||
if (nameEl){
|
||||
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
|
||||
}
|
||||
const emailEl = node.querySelector('.js-email');
|
||||
if (emailEl) emailEl.textContent = e.email || '';
|
||||
|
||||
node.addEventListener('click', () => {
|
||||
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
|
||||
node.classList.add('active');
|
||||
hidden.value = String(e.id);
|
||||
submit.disabled = false;
|
||||
submit.removeAttribute('disabled');
|
||||
});
|
||||
listEl.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
const run = debounce(async (q) => {
|
||||
submit.disabled = true;
|
||||
hidden.value = '';
|
||||
try { render(await searchEmployees(q)); }
|
||||
catch { listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>'; }
|
||||
}, 200);
|
||||
|
||||
input.addEventListener('input', () => run(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter'){
|
||||
const cur = listEl.querySelector('.list-group-item.active') || listEl.querySelector('.list-group-item');
|
||||
if (cur && cur.dataset.id){
|
||||
e.preventDefault();
|
||||
cur.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
input.value = '';
|
||||
hidden.value = '';
|
||||
submit.disabled = true;
|
||||
input.focus();
|
||||
run('');
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!hidden.value){
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ===== Модал «Удалить участника»: локальный выбор ===== #}
|
||||
<script>
|
||||
(function(){
|
||||
const modal = document.getElementById('removeMemberModal-{{ dept.id }}');
|
||||
if (!modal) return;
|
||||
|
||||
const form = modal.querySelector('form');
|
||||
const input = document.getElementById('removeMemberSearch-{{ dept.id }}');
|
||||
const listBox = document.getElementById('removeMemberResults-{{ dept.id }}');
|
||||
const hiddenEmp = document.getElementById('removeMemberChosen-{{ dept.id }}');
|
||||
const hiddenLink= document.getElementById('removeMemberLink-{{ dept.id }}');
|
||||
const submit = document.getElementById('removeMemberSubmit-{{ dept.id }}');
|
||||
if (!form || !input || !listBox || !hiddenEmp || !hiddenLink || !submit) return;
|
||||
|
||||
function norm(s){ return String(s||'').toLowerCase(); }
|
||||
function clearActive(){ listBox.querySelectorAll('.active').forEach(el => el.classList.remove('active')); }
|
||||
function matches(item, q){
|
||||
const name = norm(item.getAttribute('data-name') || '');
|
||||
const email = norm(item.getAttribute('data-email') || '');
|
||||
const id = String(item.getAttribute('data-id') || '');
|
||||
return !q || name.includes(q) || email.includes(q) || id.includes(q);
|
||||
}
|
||||
function applyFilter(){
|
||||
const q = norm(input.value);
|
||||
listBox.querySelectorAll('.list-group-item, .list-group-item-action').forEach(it => {
|
||||
const ok = matches(it, q);
|
||||
it.classList.toggle('d-none', !ok);
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', applyFilter);
|
||||
|
||||
listBox.addEventListener('click', (e) => {
|
||||
const it = e.target.closest('[data-id]');
|
||||
if (!it) return;
|
||||
clearActive();
|
||||
it.classList.add('active');
|
||||
hiddenEmp.value = it.getAttribute('data-id') || '';
|
||||
hiddenLink.value = it.getAttribute('data-link-id') || '';
|
||||
submit.disabled = !(hiddenEmp.value || hiddenLink.value);
|
||||
if (!submit.disabled) submit.removeAttribute('disabled');
|
||||
});
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
input.value = '';
|
||||
clearActive();
|
||||
applyFilter();
|
||||
hiddenEmp.value = '';
|
||||
hiddenLink.value = '';
|
||||
submit.disabled = true;
|
||||
input.focus();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!hiddenEmp.value && !hiddenLink.value){
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ===== Модал «Назначить роль»: подстановка empId в action ===== #}
|
||||
<script>
|
||||
(function(){
|
||||
const modal = document.getElementById('setRoleModal-{{ dept.id }}');
|
||||
if (!modal) return;
|
||||
|
||||
const form = modal.querySelector('form');
|
||||
const actionTpl = form ? form.getAttribute('data-action-template') : null;
|
||||
const input = document.getElementById('setRoleSearch-{{ dept.id }}');
|
||||
const listBox = document.getElementById('setRoleResults-{{ dept.id }}');
|
||||
const hidden = document.getElementById('setRoleChosen-{{ dept.id }}');
|
||||
const select = document.getElementById('setRoleSelect-{{ dept.id }}');
|
||||
const submit = document.getElementById('setRoleSubmit-{{ dept.id }}');
|
||||
if (!form || !input || !listBox || !hidden || !select || !submit) return;
|
||||
|
||||
function setAction(empId){
|
||||
if (!actionTpl) return;
|
||||
// /employees/0/departments/<deptId>/set-role/ -> /employees/<empId>/departments/<deptId>/set-role/
|
||||
const newAction = actionTpl.replace(/(\/employees\/)(\d+)(\/)/, '$1' + String(empId) + '$3');
|
||||
form.setAttribute('action', newAction);
|
||||
}
|
||||
|
||||
function norm(s){ return String(s||'').toLowerCase(); }
|
||||
function clearActive(){ listBox.querySelectorAll('.active').forEach(el => el.classList.remove('active')); }
|
||||
function matches(item, q){
|
||||
const name = norm(item.getAttribute('data-name') || '');
|
||||
const email = norm(item.getAttribute('data-email') || '');
|
||||
const id = String(item.getAttribute('data-id') || '');
|
||||
return !q || name.includes(q) || email.includes(q) || id.includes(q);
|
||||
}
|
||||
function applyFilter(){
|
||||
const q = norm(input.value);
|
||||
listBox.querySelectorAll('.list-group-item, .list-group-item-action').forEach(it => {
|
||||
const ok = matches(it, q);
|
||||
it.classList.toggle('d-none', !ok);
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', applyFilter);
|
||||
|
||||
listBox.addEventListener('click', (e) => {
|
||||
const it = e.target.closest('[data-id]');
|
||||
if (!it) return;
|
||||
clearActive();
|
||||
it.classList.add('active');
|
||||
const empId = it.getAttribute('data-id') || '';
|
||||
hidden.value = empId;
|
||||
if (empId) {
|
||||
setAction(empId);
|
||||
submit.disabled = false;
|
||||
submit.removeAttribute('disabled');
|
||||
} else {
|
||||
submit.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
input.value = '';
|
||||
clearActive();
|
||||
applyFilter();
|
||||
hidden.value = '';
|
||||
submit.disabled = true;
|
||||
input.focus();
|
||||
if (actionTpl) form.setAttribute('action', actionTpl);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!hidden.value){
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!window.bootstrap) return;
|
||||
document.querySelectorAll('[data-tooltip="1"]').forEach(el => {
|
||||
try { new bootstrap.Tooltip(el); } catch(_) {}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,48 @@
|
||||
{# ===== ПРАВАЯ КОЛОНКА: настройки + лента ===== #}
|
||||
{% if user.is_staff or user.is_superuser or can_manage or dept.head.id == user.id %}
|
||||
<div id="deptEditWrap-{{ dept.id }}" class="collapse mb-4">
|
||||
<article class="feed-card mb-3">
|
||||
<div class="p-4">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi-gear"></i>
|
||||
<div class="ios-section-title mb-0">Настройки отдела</div>
|
||||
<div class="ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
data-bs-toggle="collapse" data-bs-target="#deptEditWrap-{{ dept.id }}">
|
||||
Скрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="feed-card mb-3">
|
||||
<div class="feed-hd">
|
||||
<i class="bi-newspaper"></i>
|
||||
<div class="feed-meta">
|
||||
<div class="feed-author">Публикации отдела</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if posts %}
|
||||
{% include "feed/_feed_cards.html" with items=posts %}
|
||||
{% else %}
|
||||
<div class="text-secondary small">Публикаций пока нет.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Простая пагинация (если нужно) #}
|
||||
{% if page|default:0 %}
|
||||
<div>
|
||||
{% if page|add:"-1" %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?page={{ page|add:'-1' }}">← Назад</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -0,0 +1,58 @@
|
||||
{# ==== СТИЛИ (можно вынести в base.html, чтобы не дублировать) ==== #}
|
||||
<style>
|
||||
:root{
|
||||
--wheel-size: 200px; /* размер круга */
|
||||
--ava-size: 52px; /* размер мини-аватарки */
|
||||
--ring-bg: radial-gradient(ellipse at center, var(--bs-body-bg), color-mix(in srgb, var(--bs-body-bg) 85%, var(--bs-secondary-bg, #f7f7f9) 15%));
|
||||
--ring-border: var(--bs-border-color, rgba(0,0,0,.125));
|
||||
--shadow-inset: 0 8px 24px rgba(0,0,0,.06) inset;
|
||||
--shadow-out: 0 4px 12px rgba(0,0,0,.05);
|
||||
--arc-width: 4px;
|
||||
--arc-track: color-mix(in srgb, var(--bs-secondary-color) 30%, transparent);
|
||||
--arc-prog: var(--bs-primary);
|
||||
}
|
||||
|
||||
.team-wrap{ width: var(--wheel-size); display:flex; flex-direction:column; align-items:center; margin-inline:auto; }
|
||||
|
||||
.team-wheel{
|
||||
position: relative;
|
||||
width: var(--wheel-size);
|
||||
height: var(--wheel-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--ring-bg);
|
||||
border: 1px solid var(--ring-border);
|
||||
box-shadow: var(--shadow-inset);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
|
||||
.wheel-plane{ position:absolute; inset:0; }
|
||||
.wheel-columns{
|
||||
position:absolute; inset:10px 8px;
|
||||
display:flex; gap:8px; justify-content:space-between; align-items:flex-start;
|
||||
will-change: transform;
|
||||
}
|
||||
.wheel-col{ width: calc(25% - 6px); display:flex; flex-direction:column; gap:8px; align-items:center; }
|
||||
.wheel-col.col-2{ margin-top: calc(var(--ava-size) * .5); }
|
||||
.wheel-col.col-3{ margin-top: calc(var(--ava-size) * 1.0); }
|
||||
.wheel-col.col-4{ margin-top: calc(var(--ava-size) * 1.5); }
|
||||
|
||||
.wheel-item{ background:transparent; border:0; padding:0; width:var(--ava-size); height:var(--ava-size); border-radius:50%; cursor:pointer; box-shadow: var(--shadow-out); }
|
||||
.wheel-item .ava{ width:100%; height:100%; border-radius:50%; overflow:hidden; display:flex; align-items:center; justify-content:center; background: color-mix(in srgb, var(--bs-body-bg) 75%, #f0f0f3 25%); }
|
||||
.wheel-item .ava img{ width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.wheel-item .ava i{ font-size: calc(var(--ava-size) * .55); color: var(--bs-secondary-color); }
|
||||
|
||||
.wheel-focus{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background: var(--bs-body-bg); }
|
||||
.wheel-focus .focus-inner{ width:100%; height:100%; border-radius:50%; overflow:hidden; display:flex; align-items:center; justify-content:center; background: color-mix(in srgb, var(--bs-body-bg) 85%, #e9ecef 15%); }
|
||||
.wheel-focus img{ width:100%; height:100%; object-fit:cover; display:none; }
|
||||
.wheel-focus [data-focus-fallback]{ display:none; font-size: calc(var(--wheel-size) * .35); color: var(--bs-secondary-color); }
|
||||
|
||||
.scroll-arc{ position:absolute; inset:0; pointer-events:none; }
|
||||
.arc-track{ fill:none; stroke: var(--arc-track); stroke-width: var(--arc-width); stroke-linecap: round; }
|
||||
.arc-prog{ fill:none; stroke: var(--arc-prog); stroke-width: var(--arc-width); stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .12s linear; }
|
||||
|
||||
.team-info{ text-align:center; }
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
{# --- Команда отдела (круг) --- #}
|
||||
<section class="feed-card mb-3">
|
||||
<div class="feed-hd">
|
||||
|
||||
<i class="bi-people"></i>
|
||||
<div class="feed-meta">
|
||||
<div class="feed-author">{% url 'employees:employees_list' as employees_list_url %}
|
||||
{% if employees_list_url %}
|
||||
<a class="feed-author"
|
||||
href="{{ employees_list_url }}?department={{ dept_id }}">
|
||||
Команда отдела →
|
||||
</a>
|
||||
{% endif %} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feed-body d-flex flex-column align-items-center">
|
||||
<div class="team-wrap d-flex flex-column align-items-center">
|
||||
|
||||
{# КРУГ #}
|
||||
<div id="teamWheel-{{ dept.id }}" class="team-wheel"
|
||||
aria-live="polite"
|
||||
aria-label="Сотрудники отдела (скролл)">
|
||||
<div class="wheel-plane" data-plane>
|
||||
<div class="wheel-columns" data-columns>
|
||||
<div class="wheel-col col-1" data-col="1"></div>
|
||||
<div class="wheel-col col-2" data-col="2"></div>
|
||||
<div class="wheel-col col-3" data-col="3"></div>
|
||||
<div class="wheel-col col-4" data-col="4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Слой фокуса #}
|
||||
<div class="wheel-focus d-none" data-focus>
|
||||
<div class="focus-inner">
|
||||
<img alt="" data-focus-img>
|
||||
<i class="bi-person" data-focus-fallback></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ПОЛУКРУЖНЫЙ ИНДИКАТОР СКРОЛЛА (справа по дуге) #}
|
||||
<svg class="scroll-arc" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||
<path class="arc-track" d="M 50 2 A 48 48 0 0 1 50 98"></path>
|
||||
<path class="arc-prog" d="M 50 2 A 48 48 0 0 1 50 98" pathLength="1"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{# Источник данных для круга #}
|
||||
<div id="teamData-{{ dept.id }}" class="d-none" data-team-source>
|
||||
{% for li in links %}
|
||||
{% with emp=li.employee %}
|
||||
<div
|
||||
data-id="{{ emp.id }}"
|
||||
data-name="{{ emp.display_name|default:emp.last_name }}"
|
||||
data-role="{{ li.role.name|default:'' }}"
|
||||
data-email="{{ emp.email|default:'' }}"
|
||||
data-avatar="{{ emp.avatar|default:'' }}"
|
||||
data-active="{{ li.is_active|yesno:'1,0' }}"
|
||||
></div>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Текст под кругом #}
|
||||
<div class="mt-2 text-center w-100 d-flex flex-column align-items-center">
|
||||
<div class="team-info d-none mt-1" data-details>
|
||||
<div class="fw-semibold" data-details-name></div>
|
||||
<div class="text-secondary small" data-details-role></div>
|
||||
<div class="small" data-details-email></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -0,0 +1,10 @@
|
||||
{# employees/components/employee_form_actions.html #}
|
||||
{# Кнопки сохранения и отмены формы #}
|
||||
|
||||
<div class="col-12 d-flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="btn-label"><i class="bi-check2 me-1"></i>Сохранить</span>
|
||||
<span class="btn-spinner d-none spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" data-bs-toggle="collapse" data-bs-target="#editFormWrap">Отмена</button>
|
||||
</div>
|
||||
@ -0,0 +1,52 @@
|
||||
{# employees/components/employee_form_contact.html #}
|
||||
{# Секция "Контакты": Email, телефон, мессенджеры #}
|
||||
|
||||
<div class="col-12">
|
||||
<div class="ios-acc mb-3">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-3">
|
||||
<i class="bi-person-lines-fill"></i>
|
||||
<div class="ios-section-title mb-0">Контакты</div>
|
||||
</div>
|
||||
<div class="ios-acc-body">
|
||||
<div class="row g-3 px-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="f_phone">Телефон</label>
|
||||
<input id="f_phone" class="form-control" name="phone_number"
|
||||
value="{{ emp.phone_number|default:'' }}" inputmode="tel"
|
||||
data-init="{{ emp.phone_number|default:'' }}" data-contact="1">
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="f_email">Email</label>
|
||||
<input id="f_email" class="form-control" type="email" name="email"
|
||||
value="{{ emp.email|default:'' }}" autocomplete="email"
|
||||
|
||||
data-init="{{ emp.email|default:'' }}" data-contact="1">
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="f_telegram">Telegram</label>
|
||||
<input id="f_telegram" class="form-control" name="telegram"
|
||||
value="{{ emp.telegram|default:'' }}" inputmode="text"
|
||||
data-init="{{ emp.telegram|default:'' }}" data-contact="1">
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="f_whatsapp">WhatsApp</label>
|
||||
<input id="f_whatsapp" class="form-control" name="whatsapp"
|
||||
value="{{ emp.whatsapp|default:'' }}" inputmode="tel"
|
||||
data-init="{{ emp.whatsapp|default:'' }}" data-contact="1">
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="f_wechat">WeChat</label>
|
||||
<input id="f_wechat" class="form-control" name="wechat"
|
||||
value="{{ emp.wechat|default:'' }}" inputmode="text"
|
||||
data-init="{{ emp.wechat|default:'' }}" data-contact="1">
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,50 @@
|
||||
{# employees/components/employee_form_personal.html #}
|
||||
{# Секция "Личные данные": ФИО, пол, дата рождения #}
|
||||
|
||||
<div class="col-12">
|
||||
<div class="ios-acc mb-3">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-3">
|
||||
<i class="bi-person-badge"></i>
|
||||
<div class="ios-section-title mb-0">Личные данные</div>
|
||||
</div>
|
||||
<div class="ios-acc-body">
|
||||
<div class="row g-3 px-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="f_first_name">Имя</label>
|
||||
<input id="f_first_name" class="form-control" name="first_name"
|
||||
value="{{ emp.first_name|default:'' }}" autocomplete="given-name"
|
||||
data-init="{{ emp.first_name|default:'' }}">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="f_last_name">Фамилия</label>
|
||||
<input id="f_last_name" class="form-control" name="last_name"
|
||||
value="{{ emp.last_name|default:'' }}" autocomplete="family-name"
|
||||
data-init="{{ emp.last_name|default:'' }}">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="f_patronymic">Отчество</label>
|
||||
<input id="f_patronymic" class="form-control" name="patronymic"
|
||||
value="{{ emp.patronymic|default:'' }}"
|
||||
data-init="{{ emp.patronymic|default:'' }}">
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" for="f_gender">Пол</label>
|
||||
<select id="f_gender" class="form-select" name="gender"
|
||||
data-init="{{ emp.gender|default_if_none:'' }}">
|
||||
<option value="" {% if emp.gender is None %}selected{% endif %}>— Не указано —</option>
|
||||
<option value="1" {% if emp.gender == 1 %}selected{% endif %}>Мужской</option>
|
||||
<option value="2" {% if emp.gender == 2 %}selected{% endif %}>Женский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" for="f_birth">Дата рождения</label>
|
||||
<input id="f_birth" class="form-control" type="date" name="birth_date"
|
||||
value="{{ emp.birth_date|default:'' }}"
|
||||
data-init="{{ emp.birth_date|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,359 @@
|
||||
{# === ДОЛЖНОСТЬ === #}
|
||||
<div class="col-12">
|
||||
{% if can_edit_position or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="ios-acc mb-2" id="positionBlock"
|
||||
data-update-url-tpl="{% url 'employees:position_update' 0 %}"
|
||||
data-detail-url-tpl="{% url 'employees:position_detail' 0 %}"
|
||||
data-create-url="{% url 'employees:position_create' %}"
|
||||
data-assign-url="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}"
|
||||
data-can-assign="{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}1{% else %}0{% endif %}">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-3">
|
||||
<i class="bi-briefcase"></i>
|
||||
<div class="ios-section-title mb-0">Должность</div>
|
||||
</div>
|
||||
|
||||
<div class="ios-acc-body">
|
||||
<div class="row g-3 px-2">
|
||||
|
||||
{# ТЕКУЩАЯ ДОЛЖНОСТЬ #}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="f_position">Текущая должность</label>
|
||||
{% if all_positions %}
|
||||
<select id="f_position" class="form-select" name="position_id"
|
||||
data-init="{{ emp.position.id|default:'' }}">
|
||||
<option value="">— Не указано —</option>
|
||||
{% for p in all_positions %}
|
||||
{% if p.id and p.name %}
|
||||
<option value="{{ p.id }}"
|
||||
{% if emp.position and emp.position.id == p.id %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input id="f_position" class="form-control" name="position_id"
|
||||
value="{{ emp.position.id|default:'' }}" placeholder="ID должности"
|
||||
data-init="{{ emp.position.id|default:'' }}">
|
||||
<div class="form-text">Передайте в контекст <code>all_positions</code> для выпадающего списка.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# СОЗДАТЬ НОВУЮ #}
|
||||
{% if can_create_position or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label d-block">Новая должность</label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="collapse" data-bs-target="#newPositionPane" aria-expanded="false" aria-controls="newPositionPane">
|
||||
Создать должность
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div id="newPositionPane" class="collapse mt-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="pos_name">Название</label>
|
||||
<input id="pos_name" class="form-control" placeholder="Напр. Руководитель отдела">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="pos_desc">Описание</label>
|
||||
<input id="pos_desc" class="form-control" placeholder="Опционально">
|
||||
</div>
|
||||
|
||||
{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="col-12">
|
||||
{% if all_groups %}
|
||||
<div class="col-12">
|
||||
<div id="pos_groups_chips" class="chips mb-2"></div>
|
||||
<input type="hidden" id="pos_groups_hidden" value="">
|
||||
</div>
|
||||
|
||||
{# Модал выбора групп для новой должности #}
|
||||
<div class="modal fade" id="groupsPickerModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Группы прав</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" class="form-control mb-3" id="groupsSearch" placeholder="Поиск по группам">
|
||||
<div id="groupsList" class="list-group">
|
||||
{% for g in all_groups %}
|
||||
{% if g.id and g.name %}
|
||||
<label class="list-group-item d-flex align-items-center gap-2">
|
||||
<input class="form-check-input me-2 group-check" type="checkbox" value="{{ g.id }}">
|
||||
<span class="flex-grow-1">{{ g.name }}</span>
|
||||
<span class="text-secondary small">#{{ g.id }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="groupsClear" class="btn btn-ghost">Сбросить</button>
|
||||
<button type="button" id="groupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="pos_groups_csv">Группы прав (id через запятую)</label>
|
||||
<input id="pos_groups_csv" class="form-control" placeholder="Напр. 1,2,3">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2 mt-1">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal" data-bs-target="#groupsPickerModal">
|
||||
Выбрать группы полномочий
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="posCreateBtn">
|
||||
<span class="lbl">Создать и назначить</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
data-bs-toggle="collapse" data-bs-target="#newPositionPane">Отмена</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="small mt-1 d-none" id="posCreateMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# РЕДАКТИРОВАТЬ ВЫБРАННУЮ #}
|
||||
<div class="col-12">
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="collapse" data-bs-target="#editPositionPane"
|
||||
id="toggleEditPositionBtn" aria-expanded="false" aria-controls="editPositionPane">
|
||||
Редактировать должность
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="editPositionPane" class="collapse mt-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="pos_edit_name">Название должности</label>
|
||||
<input id="pos_edit_name" class="form-control" placeholder="Напр. Руководитель отдела">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="pos_edit_desc">Описание</label>
|
||||
<input id="pos_edit_desc" class="form-control" placeholder="Опционально">
|
||||
</div>
|
||||
|
||||
{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="col-12">
|
||||
<label class="form-label d-block">Группы полномочий</label>
|
||||
<div id="pos_edit_groups_chips" class="chips mb-2"></div>
|
||||
<input type="hidden" id="pos_edit_groups_hidden" value="">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal" data-bs-target="#posEditGroupsPickerModal">
|
||||
Выбрать группы
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="posEditClearGroups">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Модал выбора групп для редактирования должности #}
|
||||
<div class="modal fade" id="posEditGroupsPickerModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Группы прав (должность)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" class="form-control mb-3" id="posEditGroupsSearch" placeholder="Поиск по группам">
|
||||
<div id="posEditGroupsList" class="list-group">
|
||||
{% for g in all_groups %}
|
||||
{% if g.id and g.name %}
|
||||
<label class="list-group-item d-flex align-items-center gap-2">
|
||||
<input class="form-check-input me-2 pos-edit-group-check" type="checkbox" value="{{ g.id }}">
|
||||
<span class="flex-grow-1">{{ g.name }}</span>
|
||||
<span class="text-secondary small">#{{ g.id }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="posEditGroupsClear" class="btn btn-ghost">Сбросить</button>
|
||||
<button type="button" id="posEditGroupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2 mt-1">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="posEditLoadBtn">
|
||||
<span class="lbl">Загрузить из выбранной</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="posEditSaveBtn">
|
||||
<span class="lbl">Сохранить изменения</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
data-bs-toggle="collapse" data-bs-target="#editPositionPane">Отмена</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="small mt-1 d-none" id="posEditMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{# /.row #}
|
||||
</div>{# /.ios-acc-body #}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ios-acc mb-2">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-3">
|
||||
<i class="bi-briefcase"></i>
|
||||
<div class="ios-section-title mb-0">Должность</div>
|
||||
</div>
|
||||
<div class="ios-acc-body">
|
||||
<input class="form-control" value="{% firstof emp.position.name emp.position_label '—' %}" disabled>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{# === ПЕРСОНАЛЬНЫЕ ГРУППЫ ПОЛНОМОЧИЙ === #}
|
||||
<div class="col-12">
|
||||
{% if request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="ios-acc mb-3" id="empGroupsBlock"
|
||||
data-bulk-url="{% url 'employees:employee_groups_bulk' emp.id %}"
|
||||
data-create-url="{% url 'employees:group_create' %}"
|
||||
data-delete-url="{% url 'employees:group_delete' %}">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-3">
|
||||
<i class="bi-people"></i>
|
||||
<div class="ios-section-title mb-0">Дополнительные полномочия</div>
|
||||
</div>
|
||||
<div class="ios-acc-body">
|
||||
<div class="row g-3 px-2">
|
||||
|
||||
<div class="col-12">
|
||||
<div class="small text-secondary mb-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# <<< НОВОЕ: вывод уже назначенных групп сотруднику в виде «пилюль» >>> #}
|
||||
<div class="col-12">
|
||||
<label class="form-label mb-1">Назначено сотруднику</label>
|
||||
<div id="empGroupsAssigned" class="d-flex flex-wrap gap-2">
|
||||
{% for g in emp_personal_groups %}
|
||||
{% if g.id and g.name %}
|
||||
<span class="badge rounded-pill bg-primary-subtle text-primary-emphasis border"
|
||||
data-gid="{{ g.id }}">{{ g.name }}</span>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<span class="text-secondary small">Нет персональных групп</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#empGroupsPickerModal">
|
||||
Выбрать группы
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="empGroupsAssignBtn">
|
||||
<span class="lbl">Назначить выбранные</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="empGroupsRemoveBtn">
|
||||
<span class="lbl">Снять выбранные</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="emp_groups_hidden" value="">
|
||||
<div id="empGroupsMsg" class="small mt-2 d-none"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Создать новую группу</label>
|
||||
<div class="input-group">
|
||||
<input id="empGroupsCreateName" class="form-control" placeholder="CN (имя группы)">
|
||||
<button type="button" class="btn btn-outline-secondary" id="empGroupsCreateBtn">
|
||||
<span class="lbl">Создать</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="empGroupsCreateMsg" class="small mt-2 d-none"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Удалить группу</label>
|
||||
<div class="input-group">
|
||||
<select id="empGroupsDeleteSelect" class="form-select">
|
||||
<option value="">— Выберите группу —</option>
|
||||
{% for g in all_groups %}
|
||||
{% if g.id and g.name %}
|
||||
<option value="{{ g.id }}">{{ g.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline-danger" id="empGroupsDeleteBtn">
|
||||
<span class="lbl">Удалить</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Удаляется и LDAP-группа, и объект в БД.</div>
|
||||
<div id="empGroupsDeleteMsg" class="small mt-2 d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Модал выбора групп для персонального назначения #}
|
||||
<div class="modal fade" id="empGroupsPickerModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Выбор групп</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" class="form-control mb-3" id="empGroupsSearch" placeholder="Поиск по группам">
|
||||
<div id="empGroupsList" class="list-group">
|
||||
{% for g in all_groups %}
|
||||
{% if g.id and g.name %}
|
||||
<label class="list-group-item d-flex align-items-center gap-2">
|
||||
<input class="form-check-input me-2 emp-group-check" type="checkbox" value="{{ g.id }}" data-cn="{{ g.name }}">
|
||||
<span class="flex-grow-1">{{ g.name }}</span>
|
||||
<span class="text-secondary small">#{{ g.id }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="empGroupsClear" class="btn btn-ghost">Сбросить</button>
|
||||
<button type="button" id="empGroupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,907 @@
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('apiEditForm');
|
||||
if (!form) return;
|
||||
|
||||
const endpoint = form.dataset.endpoint || '';
|
||||
if (!endpoint) { console.error('Нет endpoint для формы редактирования'); return; }
|
||||
|
||||
const CONTACT_FIELDS = ['email','phone_number','telegram','whatsapp','wechat'];
|
||||
|
||||
const btn = form.querySelector('[type="submit"]');
|
||||
const btnLabel = btn?.querySelector('.btn-label');
|
||||
const btnSpin = btn?.querySelector('.btn-spinner');
|
||||
|
||||
function setSubmitting(on){
|
||||
if (!btn) return;
|
||||
if (on){ btn.setAttribute('disabled',''); btnSpin?.classList.remove('d-none'); btnLabel?.classList.add('visually-hidden'); }
|
||||
else { btn.removeAttribute('disabled'); btnSpin?.classList.add('d-none'); btnLabel?.classList.remove('visually-hidden'); }
|
||||
}
|
||||
function clearErrors(){
|
||||
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
|
||||
form.querySelectorAll('.invalid-feedback').forEach(el => el.textContent = '');
|
||||
}
|
||||
function fieldError(name, message){
|
||||
const el = form.querySelector(`[name="${CSS.escape(name)}"]`);
|
||||
if (!el) return;
|
||||
el.classList.add('is-invalid');
|
||||
const container = el.closest('[class^="col-"]') || el.parentElement;
|
||||
let fb = container?.querySelector('.invalid-feedback');
|
||||
if (!fb) { fb = document.createElement('div'); fb.className = 'invalid-feedback'; el.after(fb); }
|
||||
fb.textContent = message || 'Некорректное значение';
|
||||
}
|
||||
|
||||
function ensureOneContact(){
|
||||
const hasAny = CONTACT_FIELDS.some(n => (form.elements[n]?.value || '').trim().length > 0);
|
||||
if (hasAny) return true;
|
||||
CONTACT_FIELDS.forEach(n => fieldError(n, 'Укажите хотя бы один контакт'));
|
||||
form.elements[CONTACT_FIELDS[0]]?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Прочитать файл в Base64
|
||||
function readFileAsDataURL(file){
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// собрать skills_ids (массив) и сравнить с init
|
||||
function collectSkillsPayload(){
|
||||
const sel = form.querySelector('select[name="skills_ids"]');
|
||||
if (!sel) return null;
|
||||
const selected = Array.from(sel.selectedOptions).map(o => o.value).filter(Boolean);
|
||||
const initCsv = sel.dataset.init || '';
|
||||
const initArr = initCsv ? initCsv.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
// сравнение (без учёта порядка)
|
||||
const norm = a => a.slice().sort().join(',');
|
||||
if (norm(selected) === norm(initArr)) return null;
|
||||
return selected;
|
||||
}
|
||||
|
||||
// собрать общие поля (кроме skills_ids и avatar)
|
||||
function buildScalarPayload(){
|
||||
const payload = {};
|
||||
const fields = form.querySelectorAll(
|
||||
'input[name]:not([type="file"]):not([name="clear_avatar"]):not(:disabled), select[name]:not([name="skills_ids"]):not(:disabled)'
|
||||
);
|
||||
|
||||
fields.forEach(el => {
|
||||
const name = el.name;
|
||||
const raw = (el.value ?? '').toString().trim();
|
||||
const init = (el.dataset.init ?? el.defaultValue ?? '').toString();
|
||||
|
||||
// Ничего не менялось
|
||||
if (raw === init) return;
|
||||
|
||||
// Пустое значение: для некоторых полей нужно слать null
|
||||
const shouldNull = (raw === '' && ['gender','birth_date','position_id'].includes(name));
|
||||
|
||||
if (shouldNull) {
|
||||
// меняем непустое init -> null (очистка)
|
||||
if (init !== '') payload[name] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Типизация gender в число
|
||||
if (name === 'gender' && raw !== '') {
|
||||
payload[name] = Number(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Остальные — как есть (пустые строки допустимы для текстовых полей)
|
||||
payload[name] = raw;
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
|
||||
if (!ensureOneContact()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try{
|
||||
const payload = buildScalarPayload();
|
||||
|
||||
// skills_ids (массив)
|
||||
const skills = collectSkillsPayload();
|
||||
if (skills) payload.skills_ids = skills;
|
||||
|
||||
// avatar (Base64) / clear
|
||||
const avatarFile = document.getElementById('f_avatar')?.files?.[0] || null;
|
||||
const clearAvatar = form.querySelector('input[name="clear_avatar"]')?.checked;
|
||||
if (avatarFile){
|
||||
try{
|
||||
const dataUrl = await readFileAsDataURL(avatarFile);
|
||||
payload.avatar = dataUrl; // Base64ImageField ожидает data URL
|
||||
}catch(_){}
|
||||
} else if (clearAvatar){
|
||||
payload.avatar = ""; // очистка
|
||||
}
|
||||
|
||||
// если нечего отправлять — просто обновим UI
|
||||
if (Object.keys(payload).length === 0){
|
||||
setSubmitting(false);
|
||||
if (window.bootstrap) {
|
||||
try { new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
|
||||
}
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!resp.ok){
|
||||
let body = null;
|
||||
try { body = await resp.json(); } catch {}
|
||||
if (body && typeof body === 'object'){
|
||||
let shown = false;
|
||||
for (const [k, v] of Object.entries(body)){
|
||||
if (k === 'detail') continue;
|
||||
const msg = Array.isArray(v) ? v.join(' ') : String(v);
|
||||
fieldError(k, msg); shown = true;
|
||||
}
|
||||
if (body.detail) alert(body.detail);
|
||||
else if (!shown) alert('Не удалось сохранить изменения (ошибка валидации).');
|
||||
} else {
|
||||
const t = await resp.text();
|
||||
alert('Не удалось сохранить изменения.\n' + (t || `HTTP ${resp.status}`));
|
||||
}
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// успех
|
||||
try { if (window.bootstrap) new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
|
||||
location.reload();
|
||||
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
alert('Не удалось сохранить изменения. Попробуйте ещё раз.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('apiEditForm');
|
||||
const block = document.getElementById('positionBlock');
|
||||
if (!form || !block) return;
|
||||
|
||||
const select = document.getElementById('f_position');
|
||||
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
|
||||
const createUrl = block.dataset.createUrl || '{% url "employees:position_create" %}';
|
||||
const assignUrl = block.dataset.assignUrl || '';
|
||||
const canAssign = block.dataset.canAssign === '1';
|
||||
|
||||
function busy(btn, on){
|
||||
const lbl = btn.querySelector('.lbl');
|
||||
const sp = btn.querySelector('.spinner-border');
|
||||
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
|
||||
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
|
||||
}
|
||||
function msg(el, text, ok){
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('d-none', !text);
|
||||
el.classList.toggle('text-success', !!ok);
|
||||
el.classList.toggle('text-danger', !ok);
|
||||
}
|
||||
|
||||
document.getElementById('posCreateBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('posCreateBtn');
|
||||
const m = document.getElementById('posCreateMsg');
|
||||
msg(m, '');
|
||||
|
||||
// ВАЛИДАЦИЯ URL'ов
|
||||
if (!createUrl || !assignUrl){
|
||||
msg(m, 'Не настроены URL создания/назначения.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ДАННЫЕ ФОРМЫ
|
||||
const name = (document.getElementById('pos_name')?.value || '').trim();
|
||||
const description = (document.getElementById('pos_desc')?.value || '').trim();
|
||||
if (!name){ msg(m, 'Укажите название должности.', false); return; }
|
||||
|
||||
// ГРУППЫ ПРАВ
|
||||
let groupIds = [];
|
||||
if (canAssign){
|
||||
const msel = document.getElementById('pos_groups'); // если есть select[multiple]
|
||||
const hid = document.getElementById('pos_groups_hidden'); // из модального выбора
|
||||
const csv = document.getElementById('pos_groups_csv'); // fallback
|
||||
|
||||
if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
|
||||
else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
// ОТПРАВКА
|
||||
busy(btn, true);
|
||||
try{
|
||||
// 1) Создаём должность
|
||||
const createPayload = { name, description };
|
||||
if (canAssign && groupIds.length) createPayload.groups = groupIds;
|
||||
|
||||
const cResp = await fetch(createUrl, {
|
||||
method:'POST',
|
||||
credentials:'same-origin',
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify(createPayload)
|
||||
});
|
||||
const cData = await (async()=>{ try{return await cResp.json()}catch{return null} })();
|
||||
if (!cResp.ok){
|
||||
msg(m, (cData?.detail || JSON.stringify(cData) || `HTTP ${cResp.status}`), false);
|
||||
return;
|
||||
}
|
||||
const newId = cData?.id;
|
||||
if (!newId){
|
||||
msg(m, 'API не вернул id новой должности.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Назначаем сотруднику
|
||||
const eResp = await fetch(assignUrl, {
|
||||
method:'POST',
|
||||
credentials:'same-origin',
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify({ position_id: newId })
|
||||
});
|
||||
const eData = await (async()=>{ try{return await eResp.json()}catch{return null} })();
|
||||
if (!eResp.ok){
|
||||
msg(m, 'Создано, но не назначено: ' + (eData?.detail || JSON.stringify(eData) || `HTTP ${eResp.status}`), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Обновляем селект и закрываем панель
|
||||
if (select){
|
||||
let opt = Array.from(select.options).find(o => o.value == String(newId));
|
||||
if (!opt){
|
||||
opt = document.createElement('option');
|
||||
opt.value = String(newId);
|
||||
opt.textContent = cData?.name || ('Должность #'+newId);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = String(newId);
|
||||
select.dataset.init = String(newId);
|
||||
}
|
||||
msg(m, 'Должность создана и назначена.', true);
|
||||
try{ if (window.bootstrap) new bootstrap.Collapse(document.getElementById('newPositionPane'), {toggle:false}).hide(); }catch(_){}
|
||||
}catch(ex){
|
||||
console.error(ex);
|
||||
msg(m, 'Неожиданная ошибка при создании/назначении.', false);
|
||||
}finally{
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const chipsBox = document.getElementById('pos_groups_chips');
|
||||
const hiddenInp = document.getElementById('pos_groups_hidden');
|
||||
const listBox = document.getElementById('groupsList');
|
||||
const searchInp = document.getElementById('groupsSearch');
|
||||
const clearBtn = document.getElementById('groupsClear');
|
||||
const doneBtn = document.getElementById('groupsDone');
|
||||
|
||||
if (!listBox || !hiddenInp) return;
|
||||
|
||||
// Текущее множество выбранных id
|
||||
const sel = new Set((hiddenInp.value || '').split(',').map(s => s.trim()).filter(Boolean));
|
||||
|
||||
// Рендер чипсов
|
||||
function renderChips(){
|
||||
if (!chipsBox) return;
|
||||
chipsBox.innerHTML = '';
|
||||
if (sel.size === 0){ return; }
|
||||
// соберём карту id->название из списка (чтобы показывать имена)
|
||||
const nameById = {};
|
||||
listBox.querySelectorAll('.group-check').forEach(ch => {
|
||||
nameById[ch.value] = ch.closest('.list-group-item').querySelector('span.flex-grow-1')?.textContent || ('#'+ch.value);
|
||||
});
|
||||
for (const id of sel){
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.dataset.id = id;
|
||||
chip.innerHTML = `${nameById[id] || ('#'+id)} <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>`;
|
||||
chipsBox.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
// Синхронизация чекбоксов ↔ выбранные
|
||||
function syncChecksFromSel(){
|
||||
listBox.querySelectorAll('.group-check').forEach(ch => {
|
||||
ch.checked = sel.has(ch.value);
|
||||
});
|
||||
}
|
||||
function syncSelFromChecks(){
|
||||
sel.clear();
|
||||
listBox.querySelectorAll('.group-check:checked').forEach(ch => sel.add(ch.value));
|
||||
}
|
||||
function saveHidden(){
|
||||
hiddenInp.value = Array.from(sel).join(',');
|
||||
}
|
||||
|
||||
// Интерактив: клик по чекбоксу
|
||||
listBox.addEventListener('change', (e) => {
|
||||
const ch = e.target.closest('.group-check');
|
||||
if (!ch) return;
|
||||
if (ch.checked) sel.add(ch.value); else sel.delete(ch.value);
|
||||
});
|
||||
|
||||
// Фильтр
|
||||
searchInp?.addEventListener('input', () => {
|
||||
const q = (searchInp.value || '').toLowerCase().trim();
|
||||
listBox.querySelectorAll('.list-group-item').forEach(row => {
|
||||
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
|
||||
const id = row.querySelector('.group-check')?.value || '';
|
||||
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
|
||||
});
|
||||
});
|
||||
|
||||
// Сброс
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
sel.clear();
|
||||
syncChecksFromSel();
|
||||
renderChips();
|
||||
saveHidden();
|
||||
});
|
||||
|
||||
// Готово
|
||||
doneBtn?.addEventListener('click', () => {
|
||||
// на случай если что-то поменяли напрямую в чекбоксах
|
||||
syncSelFromChecks();
|
||||
renderChips();
|
||||
saveHidden();
|
||||
});
|
||||
|
||||
// Удаление из чипса
|
||||
chipsBox?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
const chip = btn?.closest('.chip');
|
||||
if (!chip) return;
|
||||
sel.delete(chip.dataset.id);
|
||||
renderChips();
|
||||
syncChecksFromSel();
|
||||
saveHidden();
|
||||
});
|
||||
|
||||
// Инициализация при первом открытии модала (если уже есть значения)
|
||||
renderChips();
|
||||
syncChecksFromSel();
|
||||
|
||||
// === ДОПОЛНЕНИЕ К ВАШЕЙ КНОПКЕ "Создать и назначить" ===
|
||||
// в вашем коде уже есть место, где собираются groupIds:
|
||||
// if (msel) ... else if (csv) ...
|
||||
// добавим чтение из скрытого поля:
|
||||
const posCreateBtn = document.getElementById('posCreateBtn');
|
||||
if (posCreateBtn){
|
||||
posCreateBtn.addEventListener('click', () => {
|
||||
// прокинем выбранные id в data-атрибут кнопки, чтобы ваш обработчик прочитал
|
||||
if (hiddenInp) {
|
||||
posCreateBtn.dataset.groupsSelected = hiddenInp.value; // "1,2,3"
|
||||
}
|
||||
}, {capture:true});
|
||||
}
|
||||
|
||||
// А внутри вашего обработчика posCreateBtn используйте ещё один источник:
|
||||
// const msel = document.getElementById('pos_groups');
|
||||
// const csv = document.getElementById('pos_groups_csv');
|
||||
// const hid = document.getElementById('pos_groups_hidden');
|
||||
// if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
|
||||
// else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
// else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
//
|
||||
// и перед отправкой:
|
||||
// groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
const m = document.getElementById('groupsPickerModal');
|
||||
if (m && m.parentNode !== document.body) {
|
||||
document.body.appendChild(m);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
const wrap = document.getElementById('empGroupsBlock');
|
||||
if (!wrap) return;
|
||||
|
||||
const bulkUrl = wrap.dataset.bulkUrl || ''; // /employees/<id>/groups/bulk/
|
||||
const createUrl = wrap.dataset.createUrl || ''; // /groups/create/
|
||||
const deleteUrl = wrap.dataset.deleteUrl || ''; // /groups/delete/
|
||||
const csrftoken = document.querySelector('#apiEditForm input[name=csrfmiddlewaretoken]')?.value || '';
|
||||
|
||||
const hidden = document.getElementById('emp_groups_hidden');
|
||||
const listBox = document.getElementById('empGroupsList');
|
||||
const search = document.getElementById('empGroupsSearch');
|
||||
const clearBtn = document.getElementById('empGroupsClear');
|
||||
const doneBtn = document.getElementById('empGroupsDone');
|
||||
const msgBox = document.getElementById('empGroupsMsg');
|
||||
|
||||
const assignBtn = document.getElementById('empGroupsAssignBtn');
|
||||
const removeBtn = document.getElementById('empGroupsRemoveBtn');
|
||||
|
||||
const createName = document.getElementById('empGroupsCreateName');
|
||||
const createBtn = document.getElementById('empGroupsCreateBtn');
|
||||
const createMsg = document.getElementById('empGroupsCreateMsg');
|
||||
|
||||
const delSel = document.getElementById('empGroupsDeleteSelect');
|
||||
const delBtn = document.getElementById('empGroupsDeleteBtn');
|
||||
const delMsg = document.getElementById('empGroupsDeleteMsg');
|
||||
|
||||
const assignedBox = document.getElementById('empGroupsAssigned');
|
||||
|
||||
// --- переносим модал в body и предзаполняем чекбоксы текущими группами ---
|
||||
(function(){
|
||||
const m = document.getElementById('empGroupsPickerModal');
|
||||
if (m && m.parentNode !== document.body) document.body.appendChild(m);
|
||||
|
||||
// При открытии модала отметим чекбоксы, соответствующие уже назначенным группам
|
||||
m?.addEventListener('show.bs.modal', () => {
|
||||
const assigned = new Set(
|
||||
Array.from(assignedBox?.querySelectorAll('[data-gid]') || [])
|
||||
.map(x => x.dataset.gid)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const ids = [];
|
||||
listBox?.querySelectorAll('.emp-group-check').forEach(ch => {
|
||||
const on = assigned.has(ch.value);
|
||||
ch.checked = on;
|
||||
if (on) ids.push(ch.value);
|
||||
});
|
||||
if (hidden) hidden.value = ids.join(',');
|
||||
});
|
||||
})();
|
||||
|
||||
// --- helpers ---
|
||||
function busy(btn, on){
|
||||
if (!btn) return;
|
||||
const lbl = btn.querySelector('.lbl');
|
||||
const sp = btn.querySelector('.spinner-border');
|
||||
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
|
||||
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
|
||||
}
|
||||
|
||||
function showMsg(el, text, ok){
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('d-none', !text);
|
||||
el.classList.toggle('text-success', !!ok);
|
||||
el.classList.toggle('text-danger', !ok);
|
||||
}
|
||||
|
||||
function selectedIds(){
|
||||
if (!hidden) return [];
|
||||
const csv = (hidden.value || '').trim();
|
||||
return csv ? csv.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function setSelected(ids){
|
||||
if (hidden) hidden.value = (ids || []).join(',');
|
||||
}
|
||||
|
||||
function ensureEmptyStub(){
|
||||
if (!assignedBox) return;
|
||||
if (!assignedBox.children.length){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'text-secondary small';
|
||||
span.textContent = 'Нет персональных групп';
|
||||
assignedBox.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEmptyStub(){
|
||||
const stub = assignedBox?.querySelector('.text-secondary.small');
|
||||
if (stub) stub.remove();
|
||||
}
|
||||
|
||||
function namesDict(){
|
||||
const m = {};
|
||||
listBox?.querySelectorAll('.emp-group-check').forEach(ch => {
|
||||
const nm = ch.closest('.list-group-item')?.querySelector('.flex-grow-1')?.textContent || ('#'+ch.value);
|
||||
m[ch.value] = nm;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
function addAssignedPills(ids){
|
||||
if (!assignedBox || !ids?.length) return;
|
||||
clearEmptyStub();
|
||||
const map = namesDict();
|
||||
ids.forEach(id => {
|
||||
const key = String(id);
|
||||
if (assignedBox.querySelector(`[data-gid="${key}"]`)) return;
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'badge rounded-pill bg-primary-subtle text-primary-emphasis border';
|
||||
pill.dataset.gid = key;
|
||||
pill.textContent = map[key] || ('#' + key);
|
||||
assignedBox.appendChild(pill);
|
||||
});
|
||||
ensureEmptyStub();
|
||||
}
|
||||
|
||||
function removeAssignedPills(ids){
|
||||
if (!assignedBox || !ids?.length) return;
|
||||
ids.forEach(id => assignedBox.querySelector(`[data-gid="${String(id)}"]`)?.remove());
|
||||
ensureEmptyStub();
|
||||
}
|
||||
|
||||
// --- список/поиск/сохранение выбранных в модалке ---
|
||||
listBox?.addEventListener('change', (e) => {
|
||||
const ch = e.target.closest('.emp-group-check');
|
||||
if (!ch) return;
|
||||
const ids = new Set(selectedIds());
|
||||
if (ch.checked) ids.add(ch.value); else ids.delete(ch.value);
|
||||
setSelected(Array.from(ids));
|
||||
});
|
||||
|
||||
search?.addEventListener('input', () => {
|
||||
if (!listBox) return;
|
||||
const q = (search.value || '').toLowerCase().trim();
|
||||
listBox.querySelectorAll('.list-group-item').forEach(row => {
|
||||
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
|
||||
const id = row.querySelector('.emp-group-check')?.value || '';
|
||||
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
|
||||
});
|
||||
});
|
||||
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
setSelected([]);
|
||||
listBox?.querySelectorAll('.emp-group-check').forEach(ch => ch.checked = false);
|
||||
});
|
||||
|
||||
doneBtn?.addEventListener('click', () => {
|
||||
// выбор уже сохранён в hidden по change
|
||||
});
|
||||
|
||||
// --- сетевой helper ---
|
||||
async function postJSON(url, body){
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type':'application/json',
|
||||
'Accept':'application/json',
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
let data = null;
|
||||
try { data = await resp.json(); } catch {}
|
||||
if (!resp.ok){
|
||||
const detail = (data && (data.detail || data.error)) || resp.statusText || ('HTTP ' + resp.status);
|
||||
throw new Error(detail);
|
||||
}
|
||||
return data || {};
|
||||
}
|
||||
|
||||
// --- bulk add/remove с обновлением «пилюль» ---
|
||||
async function bulkMembership(action){
|
||||
const ids = selectedIds().map(id => Number(id)).filter(n => !Number.isNaN(n));
|
||||
if (!ids.length){ showMsg(msgBox, 'Не выбраны группы.', false); return; }
|
||||
if (!bulkUrl){ showMsg(msgBox, 'Не настроен endpoint bulkUrl.', false); return; }
|
||||
const btn = (action === 'add') ? assignBtn : removeBtn;
|
||||
busy(btn, true); showMsg(msgBox, '', true);
|
||||
try{
|
||||
const data = await postJSON(bulkUrl, { action, group_ids: ids });
|
||||
|
||||
// считаем реально успешные id (без требования ok_ids от API)
|
||||
const failedMap = data.failed || {};
|
||||
const successIds = ids.filter(id => !(String(id) in failedMap));
|
||||
|
||||
if (action === 'add') addAssignedPills(successIds);
|
||||
else removeAssignedPills(successIds);
|
||||
|
||||
// синхронизируем чекбоксы и hidden сразу, чтоб при повторном открытии модалки всё было актуально
|
||||
const selSet = new Set(selectedIds()); // строковые значения
|
||||
successIds.forEach(id => {
|
||||
const val = String(id);
|
||||
const ch = listBox?.querySelector(`.emp-group-check[value="${val}"]`);
|
||||
if (action === 'add'){
|
||||
ch && (ch.checked = true);
|
||||
selSet.add(val);
|
||||
} else {
|
||||
ch && (ch.checked = false);
|
||||
selSet.delete(val);
|
||||
}
|
||||
});
|
||||
setSelected(Array.from(selSet));
|
||||
|
||||
const failed = Object.keys(failedMap).length;
|
||||
const ok = successIds.length;
|
||||
showMsg(msgBox, failed ? `Успех: ${ok}, ошибок: ${failed}.` : `Готово: ${ok} групп обработано.`, !failed);
|
||||
}catch(e){
|
||||
showMsg(msgBox, 'Ошибка: ' + String(e.message || e), false);
|
||||
}finally{
|
||||
busy(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
assignBtn?.addEventListener('click', () => bulkMembership('add'));
|
||||
removeBtn?.addEventListener('click', () => bulkMembership('remove'));
|
||||
|
||||
// --- Создание группы (через /groups/create/) ---
|
||||
createBtn?.addEventListener('click', async () => {
|
||||
const name = (createName.value || '').trim();
|
||||
if (!name){ showMsg(createMsg, 'Укажите имя группы (CN).', false); return; }
|
||||
if (!createUrl){ showMsg(createMsg, 'Не настроен endpoint createUrl.', false); return; }
|
||||
busy(createBtn, true); showMsg(createMsg, '', true);
|
||||
try{
|
||||
const data = await postJSON(createUrl, { name });
|
||||
|
||||
// добавить в список в модалке
|
||||
const row = document.createElement('label');
|
||||
row.className = 'list-group-item d-flex align-items-center gap-2';
|
||||
row.innerHTML = `
|
||||
<input class="form-check-input me-2 emp-group-check" type="checkbox" value="${data.id}" data-cn="${data.name}">
|
||||
<span class="flex-grow-1">${data.name}</span>
|
||||
<span class="text-secondary small">#${data.id}</span>`;
|
||||
listBox?.appendChild(row);
|
||||
|
||||
// добавить в select удаления
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(data.id); opt.textContent = data.name;
|
||||
delSel?.appendChild(opt);
|
||||
|
||||
showMsg(createMsg, 'Группа создана.', true);
|
||||
createName.value = '';
|
||||
}catch(e){
|
||||
showMsg(createMsg, 'Не удалось создать: ' + String(e.message || e), false);
|
||||
}finally{
|
||||
busy(createBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Удаление группы (через /groups/delete/) ---
|
||||
delBtn?.addEventListener('click', async () => {
|
||||
const gid = Number(delSel?.value || 0);
|
||||
if (!gid){ showMsg(delMsg, 'Выберите группу.', false); return; }
|
||||
if (!deleteUrl){ showMsg(delMsg, 'Не настроен endpoint deleteUrl.', false); return; }
|
||||
if (!confirm('Удалить группу? Действие необратимо.')) return;
|
||||
busy(delBtn, true); showMsg(delMsg, '', true);
|
||||
try{
|
||||
await postJSON(deleteUrl, { group_id: gid });
|
||||
|
||||
// убрать из UI
|
||||
listBox?.querySelector(`.emp-group-check[value="${gid}"]`)?.closest('.list-group-item')?.remove();
|
||||
delSel?.querySelector(`option[value="${gid}"]`)?.remove();
|
||||
|
||||
// убрать из выбранных
|
||||
const ids = new Set(selectedIds());
|
||||
ids.delete(String(gid));
|
||||
setSelected(Array.from(ids));
|
||||
|
||||
// убрать «пилюлю», если была назначена
|
||||
removeAssignedPills([gid]);
|
||||
|
||||
showMsg(delMsg, 'Группа удалена.', true);
|
||||
}catch(e){
|
||||
showMsg(delMsg, 'Не удалось удалить: ' + String(e.message || e), false);
|
||||
}finally{
|
||||
busy(delBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
// при загрузке гарантируем плейсхолдер «Нет персональных групп», если нужно
|
||||
ensureEmptyStub();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('apiEditForm');
|
||||
const block = document.getElementById('positionBlock');
|
||||
const select = document.getElementById('f_position');
|
||||
if (!form || !block || !select) return;
|
||||
|
||||
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
|
||||
const updateTpl = block.dataset.updateUrlTpl || '';
|
||||
const detailTpl = block.dataset.detailUrlTpl || ''; // ← фронтовая detail-вьюха
|
||||
const canAssign = block.dataset.canAssign === '1';
|
||||
|
||||
const nameInp = document.getElementById('pos_edit_name');
|
||||
const descInp = document.getElementById('pos_edit_desc');
|
||||
const chipsBox = document.getElementById('pos_edit_groups_chips');
|
||||
const hidden = document.getElementById('pos_edit_groups_hidden');
|
||||
const msgBox = document.getElementById('posEditMsg');
|
||||
const loadBtn = document.getElementById('posEditLoadBtn');
|
||||
const saveBtn = document.getElementById('posEditSaveBtn');
|
||||
|
||||
const modalEl = document.getElementById('posEditGroupsPickerModal');
|
||||
const listBox = document.getElementById('posEditGroupsList');
|
||||
const search = document.getElementById('posEditGroupsSearch');
|
||||
const clearBtn = document.getElementById('posEditGroupsClear');
|
||||
const doneBtn = document.getElementById('posEditGroupsDone');
|
||||
const clearGroupsBtn = document.getElementById('posEditClearGroups');
|
||||
|
||||
if (modalEl && modalEl.parentNode !== document.body) document.body.appendChild(modalEl);
|
||||
|
||||
function busy(btn, on){
|
||||
if (!btn) return;
|
||||
const lbl = btn.querySelector('.lbl');
|
||||
const sp = btn.querySelector('.spinner-border');
|
||||
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
|
||||
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
|
||||
}
|
||||
function showMsg(text, ok){
|
||||
msgBox.textContent = text || '';
|
||||
msgBox.classList.toggle('d-none', !text);
|
||||
msgBox.classList.toggle('text-success', !!ok);
|
||||
msgBox.classList.toggle('text-danger', !ok);
|
||||
}
|
||||
|
||||
function currentIds(){ const v=(hidden?.value||'').trim(); return v? v.split(',').map(s=>s.trim()).filter(Boolean):[]; }
|
||||
function setIds(ids){ if (hidden) hidden.value=(ids||[]).join(','); renderChips(); syncChecksFromHidden(); }
|
||||
function renderChips(){
|
||||
if (!chipsBox) return;
|
||||
chipsBox.innerHTML = '';
|
||||
const ids = currentIds(); if (!ids.length) return;
|
||||
const nameById = {};
|
||||
listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=>{
|
||||
const nm = ch.closest('.list-group-item')?.querySelector('.flex-grow-1')?.textContent || ('#'+ch.value);
|
||||
nameById[ch.value] = nm;
|
||||
});
|
||||
ids.forEach(id=>{
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.dataset.id = id;
|
||||
chip.innerHTML = `${nameById[id] || ('#'+id)} <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>`;
|
||||
chipsBox.appendChild(chip);
|
||||
});
|
||||
}
|
||||
chipsBox?.addEventListener('click', (e)=>{
|
||||
const btn = e.target.closest('button'); const chip = btn?.closest('.chip'); if (!chip) return;
|
||||
const ids = new Set(currentIds()); ids.delete(chip.dataset.id); setIds(Array.from(ids));
|
||||
});
|
||||
function syncChecksFromHidden(){
|
||||
const ids = new Set(currentIds());
|
||||
listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=> ch.checked = ids.has(ch.value));
|
||||
}
|
||||
function syncHiddenFromChecks(){
|
||||
const ids=[]; listBox?.querySelectorAll('.pos-edit-group-check:checked').forEach(ch=>ids.push(ch.value));
|
||||
setIds(ids);
|
||||
}
|
||||
|
||||
search?.addEventListener('input', ()=>{
|
||||
const q=(search.value||'').toLowerCase().trim();
|
||||
listBox?.querySelectorAll('.list-group-item').forEach(row=>{
|
||||
const name=row.querySelector('.flex-grow-1')?.textContent.toLowerCase()||'';
|
||||
const id=row.querySelector('.pos-edit-group-check')?.value||'';
|
||||
row.classList.toggle('d-none', !(name.includes(q)||id.includes(q)));
|
||||
});
|
||||
});
|
||||
clearBtn?.addEventListener('click', ()=>{ listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=>ch.checked=false); setIds([]); });
|
||||
doneBtn?.addEventListener('click', syncHiddenFromChecks);
|
||||
clearGroupsBtn?.addEventListener('click', ()=> setIds([]));
|
||||
|
||||
function buildUrlFromTpl(tpl, id){
|
||||
if (!tpl) return '';
|
||||
if (tpl.includes('/0/')) return tpl.replace('/0/', `/${id}/`);
|
||||
if (tpl.endsWith('0')) return tpl.slice(0,-1) + id;
|
||||
return tpl.replace('0', String(id));
|
||||
}
|
||||
function buildUpdateUrl(posId){ return buildUrlFromTpl(updateTpl, posId); }
|
||||
function buildDetailUrl(posId){ return buildUrlFromTpl(detailTpl, posId); } // ← используем фронт-вью
|
||||
|
||||
async function loadPositionDetail(posId){
|
||||
if (!posId){ showMsg('Выберите должность в выпадающем списке.', false); return; }
|
||||
const url = buildDetailUrl(posId);
|
||||
if (!url){ showMsg('Не настроен URL detail-вьюхи должности.', false); return; }
|
||||
|
||||
showMsg('', true); busy(loadBtn, true);
|
||||
try{
|
||||
const resp = await fetch(url, {
|
||||
credentials:'same-origin',
|
||||
headers:{ 'Accept':'application/json', 'X-Requested-With':'XMLHttpRequest' }
|
||||
});
|
||||
const data = await (async()=>{ try{return await resp.json()}catch{return null} })();
|
||||
if (!resp.ok){ showMsg(data?.detail || `Не удалось загрузить должность (HTTP ${resp.status})`, false); return; }
|
||||
|
||||
nameInp && (nameInp.value = data?.name || '');
|
||||
descInp && (descInp.value = data?.description || '');
|
||||
|
||||
if (canAssign){
|
||||
const groupsRaw = Array.isArray(data?.groups) ? data.groups
|
||||
: Array.isArray(data?.groups_ids) ? data.groups_ids : [];
|
||||
const ids = groupsRaw.map(x => (typeof x==='number' ? String(x) : String(x?.id || x))).filter(Boolean);
|
||||
setIds(ids);
|
||||
}
|
||||
showMsg('Данные должности загружены.', true);
|
||||
}catch(_){
|
||||
showMsg('Ошибка загрузки должности.', false);
|
||||
}finally{
|
||||
busy(loadBtn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePositionChanges(){
|
||||
const posId = Number(select.value || 0);
|
||||
if (!posId){ showMsg('Сначала выберите должность, которую хотите редактировать.', false); return; }
|
||||
const updateUrl = buildUpdateUrl(posId);
|
||||
if (!updateUrl){ showMsg('Не настроен URL обновления должности.', false); return; }
|
||||
|
||||
const payload = {};
|
||||
const nm = (nameInp?.value || '').trim();
|
||||
const ds = (descInp?.value || '').trim();
|
||||
if (nm) payload.name = nm;
|
||||
payload.description = ds;
|
||||
if (canAssign){
|
||||
const groups = currentIds().map(x=>Number(x)).filter(n=>!Number.isNaN(n));
|
||||
payload.groups = groups;
|
||||
}
|
||||
|
||||
showMsg('', true); busy(saveBtn, true);
|
||||
try{
|
||||
const resp = await fetch(updateUrl, {
|
||||
method: 'POST', // фронт-прокси примет POST и внутри пошлёт PATCH
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await (async()=>{ try{return await resp.json()}catch{return null} })();
|
||||
if (!resp.ok){ showMsg(data?.detail || `Не удалось сохранить (HTTP ${resp.status})`, false); return; }
|
||||
|
||||
if (payload.name){
|
||||
const opt = Array.from(select.options).find(o => o.value == String(posId));
|
||||
if (opt) opt.textContent = payload.name;
|
||||
}
|
||||
|
||||
try{ if (window.bootstrap) new bootstrap.Collapse(document.getElementById('editPositionPane'), {toggle:false}).hide(); }catch(_){}
|
||||
showMsg('Изменения сохранены.', true);
|
||||
}catch(_){
|
||||
showMsg('Ошибка сохранения изменений.', false);
|
||||
}finally{
|
||||
busy(saveBtn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('toggleEditPositionBtn')?.addEventListener('click', ()=>{
|
||||
const posId = Number(select.value || 0);
|
||||
if (posId) setTimeout(()=> loadPositionDetail(posId), 150);
|
||||
else showMsg('Выберите должность в выпадающем списке.', false);
|
||||
});
|
||||
document.getElementById('posEditLoadBtn')?.addEventListener('click', ()=> loadPositionDetail(Number(select.value || 0)));
|
||||
document.getElementById('posEditSaveBtn')?.addEventListener('click', savePositionChanges);
|
||||
})();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
1025
backend/templates/employees/department_detail.html.backup
Normal file
1025
backend/templates/employees/department_detail.html.backup
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,15 +4,9 @@
|
||||
{% block title %}Отделы{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
<style>
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{ width:100%; height:40px; padding:8px 38px; border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%); font-size:15px; outline:none; transition: box-shadow .2s, border-color .2s, background .2s; }
|
||||
[data-bs-theme="dark"] .ios-search-input{ background: color-mix(in srgb, var(--bs-body-bg), #fff 6%); }
|
||||
.ios-search-input:focus{ border-color: var(--bs-primary); box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), .15); }
|
||||
.ios-search > .bi-search{ position:absolute; left:10px; top:50%; transform:translateY(-50%); opacity:.55; pointer-events:none; font-size:.95rem; }
|
||||
.ios-search-clear{ position:absolute; right:4px; top:50%; transform:translateY(-50%); border:0; background:transparent; padding:6px; display:none; }
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
/* ios-search стили теперь в static/css/components/ios-search.css */
|
||||
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
|
||||
|
||||
@ -5,32 +5,9 @@
|
||||
{% block title %}Сотрудники{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
<style>
|
||||
/* Поиск */
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{
|
||||
width:100%; height:40px; padding:8px 38px;
|
||||
border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%);
|
||||
font-size:15px; outline:none;
|
||||
transition: box-shadow .2s, border-color .2s, background .2s;
|
||||
}
|
||||
[data-bs-theme="dark"] .ios-search-input{
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
}
|
||||
.ios-search-input:focus{
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), .15);
|
||||
}
|
||||
.ios-search > .bi-search{
|
||||
position:absolute; left:10px; top:50%; transform:translateY(-50%);
|
||||
opacity:.55; pointer-events:none; font-size:.95rem;
|
||||
}
|
||||
.ios-search-clear{
|
||||
position:absolute; right:4px; top:50%; transform:translateY(-50%);
|
||||
border:0; background:transparent; padding:6px; display:none;
|
||||
}
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
/* ios-search стили теперь в static/css/components/ios-search.css */
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
|
||||
/* Токены списка сотрудников */
|
||||
|
||||
133
backend/templates/includes/calendar/calendar_styles.html
Normal file
133
backend/templates/includes/calendar/calendar_styles.html
Normal file
@ -0,0 +1,133 @@
|
||||
<style>
|
||||
/* ====== БАЗА ====== */
|
||||
.calendar-wrap{
|
||||
min-height: 360px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: inset 0 1px 3px color-mix(in srgb, var(--bs-body-color), transparent 92%);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rightbar-card{
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.08);
|
||||
min-height: 0;
|
||||
}
|
||||
.rightbar-card .card-header{
|
||||
flex: 0 0 auto;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding: .6rem .9rem;
|
||||
}
|
||||
.rightbar-card .card-body{
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 992px){
|
||||
.rightbar{
|
||||
position: sticky; top: var(--navbar-h);
|
||||
height: calc(100dvh - var(--navbar-h));
|
||||
padding: 1rem 1rem 1rem .75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== FullCalendar — компактно ====== */
|
||||
.fc{
|
||||
font-size: 0.7rem;
|
||||
--fc-border-color: var(--bs-border-color);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: transparent;
|
||||
--fc-today-bg-color: color-mix(in srgb, var(--bs-primary), transparent 85%);
|
||||
--fc-event-bg-color: var(--bs-primary);
|
||||
--fc-event-border-color: var(--bs-primary);
|
||||
--fc-event-text-color: var(--bs-body-bg);
|
||||
}
|
||||
.fc-theme-standard .fc-scrollgrid{ border: 0 !important; }
|
||||
.fc-theme-standard td, .fc-theme-standard th{ border: 0 !important; }
|
||||
.fc .fc-toolbar{ padding: .7rem .9rem; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); justify-content: space-between; }
|
||||
.fc .fc-toolbar-title{ font-size: 1rem; font-weight: 600; color: var(--bs-body-color); }
|
||||
.fc .fc-button{ border-radius: 8px; font-size: .82rem; padding: .25rem .5rem; background: transparent; border: none; color: var(--bs-primary); }
|
||||
.fc .fc-button:hover{ background: rgba(var(--bs-primary-rgb), .1); }
|
||||
.fc .fc-daygrid-day-frame{ padding: 2px 3px !important; }
|
||||
.fc .fc-daygrid-day-number{
|
||||
font-size: .82rem; width: 100%; height: 1.6rem; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; color: var(--bs-body-color); text-decoration: none !important;
|
||||
}
|
||||
.fc .fc-day-today{ background: none !important; }
|
||||
.fc .fc-day-today .fc-daygrid-day-number{
|
||||
color: var(--bs-primary); font-weight: 700; border: 1px solid var(--bs-primary); border-radius: 50%;
|
||||
}
|
||||
.fc .fc-day-other .fc-daygrid-day-number{ opacity: .4; }
|
||||
.fc .fc-daygrid-day-events{ padding: 0 2px 2px !important; row-gap: 2px !important; }
|
||||
.fc .fc-daygrid-event-harness{ margin: 0 0 2px 0 !important; }
|
||||
.fc .fc-daygrid-event .fc-event-time{ display: none !important; }
|
||||
.fc .fc-daygrid-event{
|
||||
border-radius: 6px;
|
||||
padding: .1rem .25rem !important;
|
||||
line-height: 1.02 !important;
|
||||
background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 75%);
|
||||
border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 60%);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
.fc .fc-daygrid-event .fc-event-title{
|
||||
font-size: .62rem !important; line-height: 1.02 !important; letter-spacing: -0.2px; font-weight: 600;
|
||||
font-family: "Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
display: -webkit-box !important; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||||
overflow: hidden !important; white-space: normal !important; text-overflow: ellipsis; max-height: calc(1.02em * 3);
|
||||
}
|
||||
|
||||
/* ====== Вертикальная лента недели ====== */
|
||||
.week-strip-header{ margin-top: .6rem; display: flex; align-items: center; justify-content: space-between; color: var(--bs-secondary-color); font-size: .8rem; }
|
||||
.week-vertical{ margin-top: .6rem; display: flex; flex-direction: column; gap: .4rem; }
|
||||
.week-vertical .week-row{ display: grid; grid-template-columns: 56px 1fr; align-items: start; gap: .6rem; padding: .5rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 86%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 70%); }
|
||||
.week-vertical .week-row:hover{ background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 82%); }
|
||||
.date-badge{ display: grid; place-items: center; border-radius: 10px; padding: .35rem .25rem; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 92%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 75%); text-align: center; line-height: 1.05; }
|
||||
.date-badge .dow{ font-size: .7rem; font-weight: 700; color: var(--bs-primary); text-transform: uppercase; }
|
||||
.date-badge .md { font-size: .74rem; font-weight: 700; color: var(--bs-body-color); }
|
||||
.week-vertical .content .title{ font-size: .8rem; font-weight: 600; color: var(--bs-body-color); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; }
|
||||
.week-vertical .content .meta{ margin-top: .15rem; font-size: .72rem; color: var(--bs-secondary-color); }
|
||||
|
||||
/* Offcanvas (мобайл) */
|
||||
#rightbarOffcanvas{ height: 100dvh; height: 100svh; }
|
||||
#rightbarOffcanvas .offcanvas-body{
|
||||
display: flex; flex-direction: column; gap: .6rem;
|
||||
height: calc(100dvh - var(--rb-offcanvas-head, 56px));
|
||||
height: calc(100svh - var(--rb-offcanvas-head, 56px));
|
||||
min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||
}
|
||||
#rightbarOffcanvas .calendar-wrap{ flex: 0 0 auto; }
|
||||
#rightbarOffcanvas .week-vertical{ flex: 0 0 auto; }
|
||||
|
||||
.calendar-fab{ position: fixed; right: 1rem; bottom: 1rem; z-index: 1060; width: 52px; height: 52px; border-radius: 9999px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
|
||||
|
||||
/* ====== Color picker (palette) ====== */
|
||||
.color-swatch{
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-swatch.active{
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* ====== Event details modal ====== */
|
||||
.color-dot{
|
||||
width: 12px; height: 12px; border-radius: 9999px; display: inline-block;
|
||||
vertical-align: middle; margin-right: 6px; border: 1px solid #00000022;
|
||||
}
|
||||
</style>
|
||||
1130
backend/templates/includes/rightbar_calendar.html.backup
Normal file
1130
backend/templates/includes/rightbar_calendar.html.backup
Normal file
File diff suppressed because it is too large
Load Diff
@ -311,7 +311,7 @@
|
||||
<span class="txt-close">Скрыть</span>
|
||||
</button>
|
||||
{% if obj.attachment %}
|
||||
<a href="{{ obj.attachment }}" class="btn btn-ghost d-inline-flex align-items-center gap-1">
|
||||
<a href="{{ obj.attachment }}" class="btn btn-ghost d-inline-flex align-items-center gap-1" target="_blank">
|
||||
<i class="bi-paperclip"></i><span>Вложение</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
@ -4,32 +4,9 @@
|
||||
{% block title %}Поиск{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
<style>
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{
|
||||
width:100%; height:40px; padding:8px 38px;
|
||||
border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%);
|
||||
font-size:15px; outline:none;
|
||||
transition: box-shadow .2s, border-color .2s, background .2s;
|
||||
}
|
||||
[data-bs-theme="dark"] .ios-search-input{
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
}
|
||||
.ios-search-input:focus{
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), .15);
|
||||
}
|
||||
.ios-search > .bi-search{
|
||||
position:absolute; left:10px; top:50%; transform:translateY(-50%);
|
||||
opacity:.55; pointer-events:none; font-size:.95rem;
|
||||
}
|
||||
.ios-search-clear{
|
||||
position:absolute; right:4px; top:50%; transform:translateY(-50%);
|
||||
border:0; background:transparent; padding:6px; display:none;
|
||||
}
|
||||
.ios-search-clear .bi-x-lg{ opacity:.6; font-size:.9rem; }
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
/* ios-search стили теперь в static/css/components/ios-search.css */
|
||||
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
.result-row { cursor:default; }
|
||||
|
||||
47
backend/templates/test_dropdown.html
Normal file
47
backend/templates/test_dropdown.html
Normal file
@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Тест Bootstrap Dropdown</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1>Тест Bootstrap Dropdown</h1>
|
||||
|
||||
<div class="dropdown mt-4">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Dropdown button
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||
<li><a class="dropdown-item" href="#">Another action</a></li>
|
||||
<li><a class="dropdown-item" href="#">Something else here</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="status" class="mt-4">
|
||||
<p>Проверка Bootstrap JS: <span id="bootstrap-status">...</span></p>
|
||||
<p>Версия Bootstrap: <span id="bootstrap-version">...</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const statusEl = document.getElementById('bootstrap-status');
|
||||
const versionEl = document.getElementById('bootstrap-version');
|
||||
|
||||
if (window.bootstrap) {
|
||||
statusEl.textContent = '✓ Загружен';
|
||||
statusEl.style.color = 'green';
|
||||
versionEl.textContent = bootstrap.Dropdown ? 'Dropdown доступен' : 'Dropdown НЕ доступен';
|
||||
} else {
|
||||
statusEl.textContent = '✗ НЕ загружен';
|
||||
statusEl.style.color = 'red';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
164
backend/tests/test_template_components.py
Normal file
164
backend/tests/test_template_components.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""
|
||||
Тесты для проверки синтаксиса компонентов шаблонов.
|
||||
Проверяет, что компоненты не содержат тегов, принадлежащих родительским layout'ам.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.test import TestCase
|
||||
from django.template import loader, TemplateSyntaxError
|
||||
|
||||
|
||||
class TemplateComponentsTestCase(TestCase):
|
||||
"""Тесты синтаксиса компонентов шаблонов"""
|
||||
|
||||
# Список всех компонентов для проверки
|
||||
COMPONENTS = [
|
||||
# Компоненты employees/department
|
||||
'employees/components/department_header.html',
|
||||
'employees/components/department_info.html',
|
||||
'employees/components/department_team_circle.html',
|
||||
'employees/components/department_sidebar.html',
|
||||
'employees/components/department_modals.html',
|
||||
'employees/components/department_scripts.html',
|
||||
'employees/components/department_styles.html',
|
||||
|
||||
# Компоненты employees/employee_form
|
||||
'employees/components/employee_form_personal.html',
|
||||
'employees/components/employee_form_contact.html',
|
||||
'employees/components/employee_form_position.html',
|
||||
'employees/components/employee_form_actions.html',
|
||||
'employees/components/employee_form_scripts.html',
|
||||
'employees/_employee_edit.html',
|
||||
]
|
||||
|
||||
# Главные шаблоны, которые используют компоненты
|
||||
MAIN_TEMPLATES = [
|
||||
'employees/department_detail.html',
|
||||
'includes/rightbar_calendar.html',
|
||||
]
|
||||
|
||||
# Недопустимые теги в компонентах (они должны быть только в layout'ах)
|
||||
FORBIDDEN_TAGS = [
|
||||
'{% extends ',
|
||||
'{% block content %}',
|
||||
'{% block extra_css %}',
|
||||
'{% block extra_js %}',
|
||||
]
|
||||
|
||||
def test_components_syntax(self):
|
||||
"""Проверка синтаксиса всех компонентов через Django template loader"""
|
||||
errors = []
|
||||
|
||||
for template_path in self.COMPONENTS:
|
||||
try:
|
||||
loader.get_template(template_path)
|
||||
except TemplateSyntaxError as e:
|
||||
errors.append(f"{template_path}: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"{template_path}: Unexpected error - {e}")
|
||||
|
||||
if errors:
|
||||
error_msg = "\n".join([f" - {err}" for err in errors])
|
||||
self.fail(f"Found {len(errors)} template syntax errors:\n{error_msg}")
|
||||
|
||||
def test_main_templates_syntax(self):
|
||||
"""Проверка синтаксиса главных шаблонов"""
|
||||
errors = []
|
||||
|
||||
for template_path in self.MAIN_TEMPLATES:
|
||||
try:
|
||||
loader.get_template(template_path)
|
||||
except TemplateSyntaxError as e:
|
||||
errors.append(f"{template_path}: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"{template_path}: Unexpected error - {e}")
|
||||
|
||||
if errors:
|
||||
error_msg = "\n".join([f" - {err}" for err in errors])
|
||||
self.fail(f"Found {len(errors)} main template syntax errors:\n{error_msg}")
|
||||
|
||||
def test_components_no_forbidden_tags(self):
|
||||
"""Проверка, что компоненты не содержат тегов layout'ов"""
|
||||
from django.conf import settings
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
|
||||
errors = []
|
||||
|
||||
for template_path in self.COMPONENTS:
|
||||
file_path = templates_dir / template_path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Проверка на недопустимые теги
|
||||
for forbidden_tag in self.FORBIDDEN_TAGS:
|
||||
if forbidden_tag in content:
|
||||
errors.append(f"{template_path}: contains forbidden tag '{forbidden_tag}'")
|
||||
|
||||
if errors:
|
||||
error_msg = "\n".join([f" - {err}" for err in errors])
|
||||
self.fail(f"Found {len(errors)} components with forbidden layout tags:\n{error_msg}")
|
||||
|
||||
def test_components_no_orphan_endblock(self):
|
||||
"""Проверка, что компоненты не содержат одиноких {% endblock %}"""
|
||||
from django.conf import settings
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
|
||||
errors = []
|
||||
|
||||
for template_path in self.COMPONENTS:
|
||||
file_path = templates_dir / template_path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Проверка на {% endblock %} (должен быть только в layout'ах)
|
||||
if '{% endblock %}' in content or '{% endblock' in content:
|
||||
# Получаем номера строк с endblock
|
||||
lines_with_endblock = [
|
||||
i + 1 for i, line in enumerate(content.splitlines())
|
||||
if '{% endblock' in line
|
||||
]
|
||||
errors.append(
|
||||
f"{template_path}: contains orphan {{% endblock %}} on line(s) {lines_with_endblock}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
error_msg = "\n".join([f" - {err}" for err in errors])
|
||||
self.fail(f"Found {len(errors)} components with orphan endblock tags:\n{error_msg}")
|
||||
|
||||
def test_balanced_tags(self):
|
||||
"""Проверка баланса открывающих/закрывающих тегов"""
|
||||
from django.conf import settings
|
||||
templates_dir = Path(settings.BASE_DIR) / 'templates'
|
||||
|
||||
errors = []
|
||||
|
||||
tag_pairs = [
|
||||
('{% if ', '{% endif %}'),
|
||||
('{% for ', '{% endfor %}'),
|
||||
('{% with ', '{% endwith %}'),
|
||||
]
|
||||
|
||||
for template_path in self.COMPONENTS:
|
||||
file_path = templates_dir / template_path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
for open_tag, close_tag in tag_pairs:
|
||||
open_count = content.count(open_tag)
|
||||
close_count = content.count(close_tag)
|
||||
|
||||
if open_count != close_count:
|
||||
errors.append(
|
||||
f"{template_path}: unbalanced tags - "
|
||||
f"{open_count} × '{open_tag}' but {close_count} × '{close_tag}'"
|
||||
)
|
||||
|
||||
if errors:
|
||||
error_msg = "\n".join([f" - {err}" for err in errors])
|
||||
self.fail(f"Found {len(errors)} components with unbalanced tags:\n{error_msg}")
|
||||
Loading…
x
Reference in New Issue
Block a user