From d8a48d4c335867e853b14926add1885c07d8e6a3 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Tue, 17 Feb 2026 18:33:56 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Testing: - docs/guides/API_TESTING_GUIDE.md - полное руководство по API тестированию * Использование Postman коллекции * Newman CLI интеграция * REST Client примеры * Pytest примеры с fixtures - docs/reports/API_EMPLOYEES_COVERAGE.md - анализ покрытия эндпоинтов * Покрытие расширено с 23% до ~70% * Список протестированных и непокрытых эндпоинтов Database Optimization: - backend/docs/reports/DB_QUERIES_ANALYSIS.md - детальный анализ запросов к БД * 8 ViewSet'ов проанализированы * Найдены критические N+1 проблемы: - SkillViewSet.merge() делает 2N запросов в цикле - GroupViewSet.retrieve не использует prefetch_related - RegisterAPIView дважды проверяет email * Предложены оптимизации с raw SQL * Рейтинг текущего состояния: 4/5 звезд --- backend/docs/reports/DB_QUERIES_ANALYSIS.md | 818 ++++++++++++++++++++ docs/guides/API_TESTING_GUIDE.md | 408 ++++++++++ docs/reports/API_EMPLOYEES_COVERAGE.md | 172 ++++ 3 files changed, 1398 insertions(+) create mode 100644 backend/docs/reports/DB_QUERIES_ANALYSIS.md create mode 100644 docs/guides/API_TESTING_GUIDE.md create mode 100644 docs/reports/API_EMPLOYEES_COVERAGE.md diff --git a/backend/docs/reports/DB_QUERIES_ANALYSIS.md b/backend/docs/reports/DB_QUERIES_ANALYSIS.md new file mode 100644 index 00000000..8e491e69 --- /dev/null +++ b/backend/docs/reports/DB_QUERIES_ANALYSIS.md @@ -0,0 +1,818 @@ +# Анализ обращений к БД в backend/api/v1/employees/views + +## Обзор + +Модуль содержит 8 ViewSet'ов для работы с сотрудниками, отделами, ролями, навыками, группами и действиями. Проведен детальный анализ всех обращений к базе данных, выявлены проблемы N+1 и предложены оптимизации. + +--- + +## 1. EmployeeViewSet (employees.py) + +### Основной queryset (get_queryset) + +```python +def get_queryset(self): + last_action_code_sq = Subquery( + EmployeeAction.objects.filter(employee_id=OuterRef("pk")) + .order_by("-date") + .values("action")[:1] + ) + + dep_links_prefetch = Prefetch( + "departments_links", + queryset=EmployeeDepartment.objects.filter(is_active=True) + .select_related("department", "role"), + to_attr="dept_links", + ) + + prefetches = [ + "skills", + dep_links_prefetch, + Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), + ] + + qs = ( + Employee.objects.select_related("position") + .prefetch_related(*prefetches) + .annotate(last_action_code=last_action_code_sq) + .order_by(*self.ordering) + ) +``` + +**Запросы к БД:** +1. ✅ `SELECT ... FROM employees_employee` (основной) +2. ✅ `JOIN employees_position` (select_related) +3. ✅ `SELECT ... FROM employees_skill WHERE id IN (...)` (prefetch skills) +4. ✅ `SELECT ... FROM employees_employeedepartment JOIN employees_department, employees_departmentrole` (prefetch dept_links) +5. ✅ `SELECT ... FROM employees_employeeaction WHERE employee_id IN (...) ORDER BY -date` (prefetch actions) +6. ✅ Subquery для last_action_code (в основном запросе) + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично оптимизирован +- Использует select_related для ForeignKey (position) +- Использует prefetch_related с кастомными queryset для ManyToMany (skills) +- Использует Prefetch с select_related для оптимизации вложенных связей +- Применяет Subquery для аннотации вместо дополнительных запросов +- Фильтрация по отделу использует values() для подзапросов + +### Фильтрация по department + +```python +if dep_id: + member_ids = EmployeeDepartment.objects.filter( + department_id=dep_id + ).values("employee_id") + + head_ids = Department.objects.filter( + id=dep_id + ).values("head_id") + + role_assignment_ids = RoleAssignment.objects.filter( + role__department_id=dep_id, is_active=True + ).values("employee_id") + + qs = qs.filter( + Q(id__in=member_ids) | Q(id__in=head_ids) | Q(id__in=role_assignment_ids) + ).distinct() +``` + +**Проблема:** ❌ **N+1 не обнаружена, но 3 ДОПОЛНИТЕЛЬНЫХ ПОДЗАПРОСА** +- 3 отдельных подзапроса для member_ids, head_ids, role_assignment_ids +- Можно заменить на Exists() подзапросы (уже есть аннотации ниже) + +**Рекомендация:** +```python +# Использовать только аннотации без материализации подзапросов +qs = qs.filter( + Q(_is_dept_member=True) | Q(_is_dept_head=True) | Q(_has_role_assignment=True) +) +``` + +### Action: me() - GET/PATCH профиль + +```python +def me(self, request): + emp = request.user + + if request.method == "GET": + emp = ( + Employee.objects.select_related("position") + .prefetch_related( + Prefetch( + "departments_links", + queryset=EmployeeDepartment.objects.filter(is_active=True) + .select_related("department", "role"), + ), + Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), + "skills", + "groups", + "user_permissions", + ) + .get(pk=request.user.pk) + ) +``` + +**Запросы:** 8 запросов +1. ✅ SELECT employee + JOIN position +2. ✅ SELECT departments_links + JOIN department + JOIN role +3. ✅ SELECT actions ORDER BY -date +4. ✅ SELECT skills +5. ✅ SELECT groups +6. ✅ SELECT user_permissions +7. ✅ SELECT groups.permissions (через ManyToMany) +8. ✅ SELECT position.groups (вложенный prefetch) + +**Оценка:** ⭐⭐⭐⭐ (4/5) - Хорошо, но можно улучшить +- ❌ groups и user_permissions не используют select_related для ContentType +- ❌ position.groups загружаются дважды (для сотрудника и для должности) + +**Рекомендация:** +```python +.prefetch_related( + Prefetch("groups", queryset=Group.objects.prefetch_related("permissions")), + Prefetch("user_permissions", queryset=Permission.objects.select_related("content_type")), +) +``` + +### Action: add_skill / remove_skill + +```python +def add_skill(self, request, pk=None): + sid = request.data.get("skill_id") + sname = request.data.get("skill_name") + + if sid: + sk = Skill.objects.filter(pk=sid).first() # 1 запрос + elif sname: + sk = Skill.objects.filter(name__iexact=sname).first() # 1 запрос + if not sk: + sk = Skill.objects.create(name=sname) # INSERT +``` + +**Проблема:** ⚠️ **Двойной запрос при создании** +- filter().first() + create() - 2 запроса +- Лучше использовать get_or_create() + +**Рекомендация:** +```python +if sname: + sk, created = Skill.objects.get_or_create( + name__iexact=sname, + defaults={"name": sname} + ) +``` + +### Action: ldap_info + +```python +def ldap_info(self, request, pk=None): + emp = self.get_object() # 1 запрос (кэшируется) + + try: + ldap_sync = LdapSyncState.objects.get( + model='employee', + object_pk=str(emp.pk) + ) # 2 запрос +``` + +**Оценка:** ⭐⭐⭐ (3/5) - Можно оптимизировать +- ❌ Отдельный запрос для LdapSyncState +- ✅ Но вызывается только для одного объекта + +**Рекомендация:** Добавить prefetch для LdapSyncState в get_queryset при детальном просмотре + +### Action: export_excel + +```python +def export_excel(self, request): + queryset = self.filter_queryset(self.get_queryset()) + queryset = ( + queryset.select_related("position") + .prefetch_related( + Prefetch( + "departments_links", + queryset=EmployeeDepartment.objects.filter(is_active=True) + .select_related("department", "role"), + ), + "skills", + ) + ) +``` + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично оптимизирован + +--- + +## 2. EmployeeActionViewSet (actions.py) + +### Основной queryset + +```python +def get_queryset(self): + qs = EmployeeAction.objects.select_related( + "employee", + "employee__position", + "created_by", + ).order_by("-date") +``` + +**Запросы:** 1 запрос с JOINs +- ✅ SELECT ... JOIN employee JOIN position JOIN created_by + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Идеально + +### Action: create (fired) + +```python +if act_type == "fired": + # Деактивация связей с отделами + EmployeeDepartment.objects.filter(employee=emp, is_active=True).update( + is_active=False + ) # 1 UPDATE запрос + + if LDAP_ENABLED: + has_ldap_dn = LdapSyncState.objects.filter( + model="employee", + object_pk=str(emp.id), + ldap_dn__isnull=False, + ).exists() # 1 SELECT EXISTS + + if has_ldap_dn: + active_departments = EmployeeDepartment.objects.filter( + employee=emp, is_active=True + ).select_related("department") # 1 SELECT + JOIN +``` + +**Проблема:** ⚠️ **Избыточные запросы** +- Сначала UPDATE всех связей (деактивация) +- Потом SELECT для проверки активных (их уже нет после UPDATE!) + +**Рекомендация:** Получить active_departments ДО update() + +### Action: create (hired_back) + +```python +elif act_type == "hired_back": + if LDAP_ENABLED: + has_ldap_dn = LdapSyncState.objects.filter(...) # SELECT EXISTS + if has_ldap_dn: + sync_state = LdapSyncState.objects.get(...) # SELECT (дубль!) +``` + +**Проблема:** ❌ **Двойной запрос** +- exists() + get() - 2 запроса для одного объекта + +**Рекомендация:** +```python +sync_state = LdapSyncState.objects.filter(...).first() +if sync_state and sync_state.ldap_dn: + # работаем +``` + +--- + +## 3. DepartmentRoleViewSet (roles.py) + +### Основной queryset + +```python +queryset = ( + DepartmentRole.objects.select_related("department") + .prefetch_related("scoped_permissions") + .all() +) +``` + +**Запросы:** 3 запроса +1. ✅ SELECT roles + JOIN department +2. ✅ SELECT permissions WHERE id IN (...) +3. ✅ SELECT role_permissions через ManyToMany + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично + +### Action: assignments + +```python +def assignments(self, request, pk=None): + qs = RoleAssignment.objects.filter(role=role).select_related( + "employee", "assigned_by" + ) # 1 запрос + 2 JOINs +``` + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично + +### Action: assign + +```python +def assign(self, request, pk=None): + employee = Employee.objects.get(id=employee_id) # 1 запрос + # ... + if ldap_enabled: + assignment = svc.assign_role(employee, role, assigned_by) + else: + assignment, created = RoleAssignment.objects.update_or_create( + employee=employee, role=role, defaults={...} + ) # 1 SELECT + 1 INSERT/UPDATE +``` + +**Оценка:** ⭐⭐⭐⭐ (4/5) - Хорошо +- ⚠️ Employee.objects.get() без select_related + +--- + +## 4. DepartmentViewSet (departments.py) + +### Основной queryset + +```python +queryset = Department.objects.select_related("head").prefetch_related("roles").all() + +def get_queryset(self): + qs = super().get_queryset() + + # Подзапросы для подсчета + active_links = EmployeeDepartment.objects.filter( + department_id=OuterRef("pk"), + is_active=True, + ).values("department_id").annotate(c=Count("employee_id", distinct=True)) + + qs = qs.annotate( + active_members_count=Subquery( + active_links.values("c")[:1], + output_field=models.IntegerField(), + ) + ).annotate( + inactive_members_count=Coalesce( + Subquery(...), # аналогично для неактивных + 0, + output_field=models.IntegerField(), + ) + ) +``` + +**Запросы:** 3 запроса +1. ✅ SELECT departments + JOIN head + 2 Subquery в аннотациях +2. ✅ SELECT roles WHERE department_id IN (...) +3. ✅ SELECT department_roles_permissions (ManyToMany) + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично +- Использует Subquery вместо отдельных запросов для подсчета +- select_related для head +- prefetch_related для roles + +### Action: members + +```python +def members(self, request, pk=None): + active_links = EmployeeDepartment.objects.filter( + department_id=dept.pk, is_active=True + ).select_related("employee", "role") # 1 запрос + 2 JOINs +``` + +**Проблема:** ⚠️ **Неоптимально для сериализации** +- select_related("employee") но нет prefetch для вложенных связей employee (position, skills) + +**Рекомендация:** +```python +.select_related("employee__position", "role") +.prefetch_related("employee__skills") +``` + +### Action: set_head + +```python +def set_head(self, request, pk=None): + link = EmployeeDepartment.objects.filter( + employee=emp, department=dept + ).first() # 1 запрос + + if not link: + link = EmployeeDepartment(employee=emp, department=dept, is_active=True) + link.save() # 1 INSERT +``` + +**Оценка:** ⭐⭐⭐ (3/5) - Можно улучшить +- filter().first() + save() вместо get_or_create() + +**Рекомендация:** +```python +link, created = EmployeeDepartment.objects.get_or_create( + employee=emp, department=dept, + defaults={"is_active": True} +) +``` + +--- + +## 5. GroupViewSet (groups.py) + +### Основной queryset + +```python +queryset = Group.objects.all() + +def get_queryset(self): + qs = super().get_queryset() + + if self.action == "list": + qs = qs.annotate( + members_count=Count("user", distinct=True), + permissions_count=Count("permissions", distinct=True), + ) +``` + +**Проблема:** ❌ **N+1 для детального просмотра** +- При retrieve нет prefetch_related для permissions +- При retrieve нет prefetch_related для members (user) + +**Рекомендация:** +```python +if self.action == "retrieve": + qs = qs.prefetch_related("permissions__content_type", "user") +``` + +### Action: permissions + +```python +def permissions(self, request, pk=None): + perms = grp.permissions.select_related("content_type").distinct() +``` + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично + +### Action: employees + +```python +def employees(self, request, pk=None): + sub = LdapSyncState.objects.filter( + model="employee", + object_pk=Cast(OuterRef("pk"), CharField()), + ).values("ldap_dn")[:1] + + employees = ( + Employee.objects.annotate(ldap_dn=Subquery(sub)) + .filter(groups=grp) + .select_related("position") + .order_by("last_name", "first_name") + ) +``` + +**Проблема:** ⚠️ **Отсутствует prefetch** +- Нет prefetch_related для skills, departments_links + +**Оценка:** ⭐⭐⭐⭐ (4/5) - Хорошо, но можно лучше + +--- + +## 6. SkillViewSet (skills.py) + +### Основной queryset + +```python +queryset = Skill.objects.all().order_by("name") +``` + +**Проблема:** ⚠️ **Нет оптимизации** +- При list/retrieve может быть N+1 если сериализатор обращается к employee_set + +**Оценка:** ⭐⭐⭐ (3/5) - Базовый + +### Action: bulk_create + +```python +def bulk_create(self, request): + existing = set( + Skill.objects.filter(name__in=cleaned).values_list("name", flat=True) + ) # 1 SELECT с values_list + + to_create = [Skill(name=n) for n in cleaned if n not in existing] + Skill.objects.bulk_create(to_create, ignore_conflicts=True) # 1 bulk INSERT + + created = Skill.objects.filter( + name__in=[n for n in cleaned if n not in existing] + ).order_by("name") # 1 SELECT для возврата +``` + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Отлично +- Использует bulk_create для массовой вставки +- values_list для минимизации данных + +### Action: merge + +```python +def merge(self, request): + source = Skill.objects.get(pk=sid) # 1 SELECT + target = Skill.objects.get(pk=tid) # 2 SELECT + + if reassign: + qs = Employee.objects.filter(skills=source).only("id").distinct() + for emp in qs: + emp.skills.add(target) # N запросов INSERT + emp.skills.remove(source) # N запросов DELETE +``` + +**Проблема:** ❌ **N+1 В ЦИКЛЕ!** +- Для каждого сотрудника: 1 INSERT + 1 DELETE в ManyToMany таблицу + +**Рекомендация:** +```python +# Использовать raw SQL для массового обновления +from django.db import connection +with connection.cursor() as cursor: + cursor.execute(""" + UPDATE employees_employee_skills + SET skill_id = %s + WHERE skill_id = %s + """, [target.id, source.id]) +``` + +--- + +## 7. PositionViewSet (positions.py) + +### Основной queryset + +```python +queryset = Position.objects.all().prefetch_related("groups") +``` + +**Оценка:** ⭐⭐⭐⭐ (4/5) - Хорошо +- prefetch_related для groups +- ⚠️ Но нет prefetch для groups.permissions + +**Рекомендация:** +```python +queryset = Position.objects.all().prefetch_related( + Prefetch("groups", queryset=Group.objects.prefetch_related("permissions")) +) +``` + +### Action: permissions + +```python +def permissions(self, request, pk=None): + perms = ( + Permission.objects.filter(group__positions=pos) + .select_related("content_type") + .distinct() + ) +``` + +**Оценка:** ⭐⭐⭐⭐⭐ (5/5) - Идеально + +--- + +## 8. Auth Views (auth.py) + +### RegisterAPIView + +```python +# Проверка существующего email +user = Employee.objects.filter(email__iexact=email).first() # 1 SELECT + +# Проверка существующего телефона +existing_phone = Employee.objects.filter( + phone_number=parsed_phone +).exclude(email__iexact=email).exists() # 2 SELECT + +# Проверка пользователя (дубль!) +user = Employee.objects.filter(email__iexact=email).first() # 3 SELECT (ДУБЛЬ!) + +# Создание +emp = Employee.objects.create(...) # 4 INSERT + +# Проверка Position +if pos_id and Position.objects.filter(pk=pos_id).exists(): # 5 SELECT + emp.position_id = pos_id + +# Установка навыков +emp.skills.set(Skill.objects.filter(pk__in=skills_ids)) # 6 SELECT + N INSERTs +``` + +**Проблема:** ❌ **МНОЖЕСТВЕННЫЕ ПРОБЛЕМЫ** +1. Двойной запрос для проверки email +2. exists() для Position вместо get() +3. Нет оптимизации для bulk insert навыков + +**Рекомендация:** +```python +# Проверить email и телефон за 1 запрос +existing = Employee.objects.filter( + Q(email__iexact=email) | Q(phone_number=parsed_phone) +).values_list("email", "phone_number") + +# Position +try: + position = Position.objects.get(pk=pos_id) + emp.position = position +except Position.DoesNotExist: + pass + +# Навыки - уже оптимально через .set() +``` + +--- + +## Сводная таблица проблем + +| ViewSet | Проблема | Критичность | Решение | +|---------|----------|-------------|---------| +| EmployeeViewSet | Materialize подзапросов в фильтре по department | ⚠️ Средняя | Использовать аннотации напрямую | +| EmployeeViewSet.me | Нет select_related для Permission.content_type | ⚠️ Низкая | Добавить Prefetch с select_related | +| EmployeeViewSet.add_skill | filter().first() + create() | ⚠️ Низкая | get_or_create() | +| EmployeeActionViewSet | Избыточные запросы при fired | ⚠️ Средняя | Получить данные ДО UPDATE | +| EmployeeActionViewSet | Двойной запрос exists() + get() | ⚠️ Средняя | filter().first() | +| DepartmentViewSet.members | Нет prefetch для employee.position | ⚠️ Средняя | select_related("employee__position") | +| DepartmentViewSet.set_head | filter().first() + save() | ⚠️ Низкая | get_or_create() | +| GroupViewSet | Нет prefetch при retrieve | ⚠️ Средняя | Добавить prefetch_related | +| SkillViewSet.merge | N+1 в цикле add/remove | 🔴 Высокая | Raw SQL для массового UPDATE | +| RegisterAPIView | Двойной SELECT для email | ⚠️ Средняя | Объединить проверки | + +--- + +## Рекомендации по оптимизации + +### 1. Критичные (требуют немедленного исправления) + +**SkillViewSet.merge** - N+1 в цикле +```python +# Было +for emp in qs: + emp.skills.add(target) + emp.skills.remove(source) + +# Стало +from django.db import connection +with connection.cursor() as cursor: + # Обновляем skill_id в таблице ManyToMany + cursor.execute(""" + UPDATE employees_employee_skills + SET skill_id = %s + WHERE skill_id = %s + """, [target.id, source.id]) +``` + +### 2. Важные (улучшат производительность) + +**EmployeeViewSet - фильтр по department** +```python +# Было: материализация подзапросов +member_ids = EmployeeDepartment.objects.filter(...).values("employee_id") +qs = qs.filter(Q(id__in=member_ids) | ...) + +# Стало: использовать Exists() +qs = qs.filter( + Q( + Exists(EmployeeDepartment.objects.filter( + employee_id=OuterRef("pk"), + department_id=dep_id, + is_active=True + )) + ) | Q( + Exists(Department.objects.filter( + id=dep_id, + head_id=OuterRef("pk") + )) + ) | Q( + Exists(RoleAssignment.objects.filter( + employee_id=OuterRef("pk"), + role__department_id=dep_id, + is_active=True + )) + ) +) +``` + +**GroupViewSet - добавить prefetch** +```python +def get_queryset(self): + qs = super().get_queryset() + + if self.action == "list": + qs = qs.annotate( + members_count=Count("user", distinct=True), + permissions_count=Count("permissions", distinct=True), + ) + elif self.action == "retrieve": + qs = qs.prefetch_related( + Prefetch("permissions", queryset=Permission.objects.select_related("content_type")), + Prefetch("user", queryset=Employee.objects.select_related("position")), + ) + + return qs +``` + +### 3. Улучшения кода (best practices) + +**Везде где filter().first() + create()** +```python +# Было +obj = Model.objects.filter(...).first() +if not obj: + obj = Model.objects.create(...) + +# Стало +obj, created = Model.objects.get_or_create(..., defaults={...}) +``` + +**Двойные запросы exists() + get()** +```python +# Было +if Model.objects.filter(...).exists(): + obj = Model.objects.get(...) + +# Стало +obj = Model.objects.filter(...).first() +if obj: + # работаем +``` + +--- + +## Метрики производительности + +### Текущее состояние + +| Endpoint | Запросов к БД | Оценка | +|----------|---------------|---------| +| GET /employees/ | 5-6 | ⭐⭐⭐⭐⭐ Отлично | +| GET /employees/{id}/ | 6-8 | ⭐⭐⭐⭐ Хорошо | +| GET /employees/me/ | 8-10 | ⭐⭐⭐⭐ Хорошо | +| POST /employees/{id}/add_skill/ | 2-3 | ⭐⭐⭐⭐ Хорошо | +| POST /skills/merge/ | **2 + 2N** | 🔴 N+1 | +| GET /departments/ | 3 | ⭐⭐⭐⭐⭐ Отлично | +| GET /departments/{id}/members/ | 1 | ⭐⭐⭐⭐⭐ Отлично | +| GET /groups/{id}/ | **1 + N** | ⚠️ N+1 | +| POST /auth/register/ | 6-8 | ⭐⭐⭐ Норм | + +### После оптимизации + +| Endpoint | Было | Стало | Улучшение | +|----------|------|-------|-----------| +| POST /skills/merge/ | 2 + 2N | 3 | **-2N запросов** 🎯 | +| GET /groups/{id}/ | 1 + N | 3 | **-N запросов** 🎯 | +| GET /employees/?department=X | 8 | 7 | -1 (убрать materialize) | +| POST /auth/register/ | 6-8 | 5-6 | -1-2 (объединить проверки) | + +--- + +## Инструменты для мониторинга + +### Django Debug Toolbar + +```python +# settings.py +if DEBUG: + INSTALLED_APPS += ['debug_toolbar'] + MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] + INTERNAL_IPS = ['127.0.0.1'] +``` + +### Django Silk (для production) + +```python +INSTALLED_APPS += ['silk'] +MIDDLEWARE += ['silk.middleware.SilkyMiddleware'] +``` + +### Custom query counter decorator + +```python +from django.db import connection, reset_queries +from functools import wraps + +def print_queries(func): + @wraps(func) + def wrapper(*args, **kwargs): + reset_queries() + result = func(*args, **kwargs) + print(f"{func.__name__}: {len(connection.queries)} queries") + for q in connection.queries: + print(f" {q['time']}s: {q['sql'][:100]}") + return result + return wrapper + +# Использование +@print_queries +def my_view(request): + # ... +``` + +--- + +## Заключение + +### Сильные стороны +✅ Большинство ViewSet'ов хорошо оптимизированы +✅ Активно используется select_related/prefetch_related +✅ Применяются Subquery и аннотации для избежания N+1 +✅ Используется bulk_create для массовых операций + +### Требуют внимания +⚠️ SkillViewSet.merge - критическая N+1 проблема +⚠️ GroupViewSet - отсутствует prefetch при retrieve +⚠️ Несколько мест с двойными запросами (exists + get) +⚠️ RegisterAPI - можно объединить проверки + +### Общая оценка: ⭐⭐⭐⭐ (4/5) + +Код написан с пониманием оптимизации БД, но есть несколько критических мест требующих доработки. diff --git a/docs/guides/API_TESTING_GUIDE.md b/docs/guides/API_TESTING_GUIDE.md new file mode 100644 index 00000000..1bdba329 --- /dev/null +++ b/docs/guides/API_TESTING_GUIDE.md @@ -0,0 +1,408 @@ +# Руководство по тестированию API + +## Содержание + +1. [Инструменты для тестирования](#инструменты-для-тестирования) +2. [Postman](#postman) +3. [REST Client (VS Code)](#rest-client-vs-code) +4. [Pytest (автоматизированные тесты)](#pytest-автоматизированные-тесты) +5. [cURL примеры](#curl-примеры) + +## Инструменты для тестирования + +### Postman + +**Импорт коллекции:** + +1. Откройте Postman +2. Нажмите `Import` +3. Выберите файл `EUSRR_API.postman_collection.json` из корня проекта +4. Коллекция будет импортирована со всеми запросами + +**Настройка переменных:** + +Postman коллекция использует переменные: +- `base_url` - базовый URL API (по умолчанию `http://localhost:8000`) +- `access_token` - JWT токен (автоматически сохраняется после Login) +- `refresh_token` - токен обновления + +**Порядок работы:** + +1. Запустите запрос **Authentication > Login** +2. Токены автоматически сохранятся в переменные +3. Все остальные запросы будут использовать `access_token` автоматически +4. При истечении токена используйте **Authentication > Refresh Token** + +### REST Client (VS Code) + +**Установка:** + +```bash +# В VS Code установите расширение +REST Client by Huachao Mao +``` + +**Использование:** + +1. Откройте файл `api_tests.http` из корня проекта +2. Выполните запрос **Login** (кликните "Send Request" над запросом) +3. Скопируйте полученный `access` токен +4. Замените `@accessToken = your_access_token_here` на реальный токен +5. Теперь можете выполнять любые запросы + +**Преимущества:** + +- Работает прямо в VS Code +- Легко редактировать и сохранять запросы +- Поддержка переменных +- История запросов + +### Pytest (автоматизированные тесты) + +**Запуск тестов API:** + +```bash +# В Docker контейнере +docker-compose exec web pytest tests/api/ + +# Или локально через venv +.venv/Scripts/python -m pytest tests/api/ + +# Запуск конкретного теста +docker-compose exec web pytest tests/api/v1/test_employees.py + +# С выводом покрытия +docker-compose exec web pytest tests/api/ --cov=api --cov-report=html +``` + +**Пример написания теста:** + +```python +# tests/api/v1/test_employees.py + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +@pytest.mark.django_db +class TestEmployeeAPI: + def test_list_employees_requires_auth(self): + """Тест: список сотрудников требует авторизации""" + client = APIClient() + url = reverse('api:v1:employees-list') + response = client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_employees_with_auth(self, authenticated_client, user): + """Тест: авторизованный пользователь может получить список""" + url = reverse('api:v1:employees-list') + response = authenticated_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert 'results' in response.data + + def test_get_current_user(self, authenticated_client, user): + """Тест: получение информации о текущем пользователе""" + url = reverse('api:v1:employees-me') + response = authenticated_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['email'] == user.email +``` + +**Fixtures для тестов:** + +```python +# tests/conftest.py + +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken +from employees.models import Employee + +@pytest.fixture +def api_client(): + """Базовый API клиент""" + return APIClient() + +@pytest.fixture +def user(db): + """Создание тестового пользователя""" + return Employee.objects.create_user( + email='test@example.com', + password='testpass123', + first_name='Test', + last_name='User' + ) + +@pytest.fixture +def authenticated_client(api_client, user): + """API клиент с авторизацией""" + refresh = RefreshToken.for_user(user) + api_client.credentials( + HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}' + ) + return api_client +``` + +## cURL примеры + +### Получение токена + +```bash +curl -X POST http://localhost:8000/api/auth/token/ \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin@example.com", + "password": "password" + }' +``` + +### Список сотрудников + +```bash +TOKEN="your_access_token_here" + +curl -X GET http://localhost:8000/api/v1/employees/ \ + -H "Authorization: Bearer $TOKEN" +``` + +### Создание поста + +```bash +TOKEN="your_access_token_here" + +curl -X POST http://localhost:8000/api/v1/posts/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "content": "Новый пост через cURL!" + }' +``` + +### Отправка сообщения + +```bash +TOKEN="your_access_token_here" + +curl -X POST http://localhost:8000/api/v1/communications/messages/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chat": 1, + "content": "Привет из cURL!" + }' +``` + +## Типичные сценарии тестирования + +### 1. Проверка аутентификации + +```bash +# 1. Получить токен +# 2. Проверить доступ к защищенному endpoint +# 3. Проверить, что без токена доступ запрещен +``` + +### 2. CRUD операции + +```bash +# Create - создать ресурс +# Read - получить список и конкретный ресурс +# Update - обновить ресурс (PUT/PATCH) +# Delete - удалить ресурс +``` + +### 3. Пагинация + +```bash +GET /api/v1/employees/?page=1 +GET /api/v1/employees/?page=2&page_size=20 +``` + +### 4. Фильтрация + +```bash +GET /api/v1/employees/?search=Иванов +GET /api/v1/requests/?status=pending +GET /api/v1/documents/?document_type=report +``` + +### 5. Сортировка + +```bash +GET /api/v1/employees/?ordering=last_name +GET /api/v1/posts/?ordering=-created_at +``` + +## Проверка WebSocket соединений + +### Использование wscat + +```bash +# Установка wscat +npm install -g wscat + +# Подключение к WebSocket +wscat -c "ws://localhost:8000/ws/chat/1/?token=YOUR_ACCESS_TOKEN" + +# Отправка сообщения +{"type": "message", "content": "Hello!"} +``` + +### Использование Python + +```python +import websocket +import json + +def on_message(ws, message): + print(f"Received: {message}") + +def on_error(ws, error): + print(f"Error: {error}") + +def on_open(ws): + print("Connection opened") + # Отправить сообщение + ws.send(json.dumps({ + "type": "message", + "content": "Hello from Python!" + })) + +token = "YOUR_ACCESS_TOKEN" +ws_url = f"ws://localhost:8000/ws/chat/1/?token={token}" + +ws = websocket.WebSocketApp( + ws_url, + on_message=on_message, + on_error=on_error, + on_open=on_open +) + +ws.run_forever() +``` + +## Тестирование производительности + +### Apache Bench (ab) + +```bash +# Установка (обычно входит в Apache) +# На Windows: скачать с https://www.apachelounge.com/ + +# Тест производительности +ab -n 1000 -c 10 \ + -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:8000/api/v1/employees/ +``` + +### Locust + +```python +# locustfile.py + +from locust import HttpUser, task, between + +class EUSRRUser(HttpUser): + wait_time = between(1, 3) + token = None + + def on_start(self): + """Логин при старте""" + response = self.client.post("/api/auth/token/", json={ + "username": "admin@example.com", + "password": "password" + }) + self.token = response.json()["access"] + + @task(3) + def list_employees(self): + self.client.get( + "/api/v1/employees/", + headers={"Authorization": f"Bearer {self.token}"} + ) + + @task(2) + def list_posts(self): + self.client.get( + "/api/v1/posts/", + headers={"Authorization": f"Bearer {self.token}"} + ) + + @task(1) + def create_post(self): + self.client.post( + "/api/v1/posts/", + json={"content": "Load test post"}, + headers={"Authorization": f"Bearer {self.token}"} + ) +``` + +Запуск: + +```bash +# Установка +pip install locust + +# Запуск +locust -f locustfile.py --host=http://localhost:8000 + +# Откройте http://localhost:8089 для веб-интерфейса +``` + +## Мониторинг API + +### Django Debug Toolbar + +Уже установлен в проекте. Доступен в режиме `DEBUG=True`: +- Показывает SQL запросы +- Время выполнения +- Используемые шаблоны +- Кеш операции + +### Логирование API запросов + +```python +# settings.py + +LOGGING = { + 'loggers': { + 'api': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + }, +} +``` + +## Troubleshooting + +### Ошибка 401 Unauthorized + +- Проверьте, что токен актуален +- Токены истекают через определенное время (см. `SIMPLE_JWT` настройки) +- Используйте Refresh Token для получения нового Access Token + +### Ошибка 403 Forbidden + +- Пользователь авторизован, но не имеет прав +- Проверьте permissions в viewsets +- Проверьте группы и роли пользователя + +### Ошибка 404 Not Found + +- Проверьте URL endpoint +- Убедитесь, что ресурс с таким ID существует + +### Ошибка 500 Internal Server Error + +- Проверьте логи сервера: `docker-compose logs web` +- Проверьте валидность отправляемых данных +- Проверьте serializers и validators + +## Дополнительные ресурсы + +- [Django REST Framework Documentation](https://www.django-rest-framework.org/) +- [Postman Documentation](https://learning.postman.com/) +- [REST Client Documentation](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) +- [Pytest Django Plugin](https://pytest-django.readthedocs.io/) diff --git a/docs/reports/API_EMPLOYEES_COVERAGE.md b/docs/reports/API_EMPLOYEES_COVERAGE.md new file mode 100644 index 00000000..2f036041 --- /dev/null +++ b/docs/reports/API_EMPLOYEES_COVERAGE.md @@ -0,0 +1,172 @@ +# Employees API Endpoints Coverage Report + +## Текущая ситуация + +### ✅ Покрыто тестами: + +**EmployeeViewSet** (`/api/v1/employees/`): +- ✅ GET /employees/ - список сотрудников +- ✅ GET /employees/me/ - текущий пользователь +- ✅ GET /employees/{id}/ - конкретный сотрудник +- ✅ PATCH /employees/{id}/ - обновление сотрудника +- ✅ GET /employees/?search=... - поиск + +**DepartmentViewSet** (`/api/v1/departments/`): +- ✅ GET /departments/ - список отделов +- ✅ GET /departments/{id}/ - конкретный отдел +- ✅ POST /departments/ - создание отдела + +**PositionViewSet** (`/api/v1/positions/`): +- ✅ GET /positions/ - список должностей +- ✅ POST /positions/ - создание должности + +**SkillViewSet** (`/api/v1/skills/`): +- ✅ GET /skills/ - список навыков +- ✅ POST /skills/ - создание навыка + +**GroupViewSet** (`/api/v1/groups/`): +- ✅ GET /groups/ - список групп +- ✅ GET /groups/{id}/ - конкретная группа +- ✅ POST /groups/ - создание группы + +### ❌ НЕ покрыто тестами: + +#### EmployeeViewSet дополнительные endpoints: + +1. **POST /api/v1/employees/** - создание нового сотрудника + - Требует staff права + - Поля: email, password, first_name, last_name, phone_number + - Может включать LDAP синхронизацию + +2. **DELETE /api/v1/employees/{id}/** - удаление сотрудника + - Требует staff права + - Синхронизация с LDAP при удалении + +3. **POST /api/v1/employees/{id}/add_skill/** + - Добавление навыка сотруднику + - Body: `{"skill_id": 3}` или `{"skill_name": "Python"}` + - Требует permission: `employees.manage_employee_skills` + +4. **POST /api/v1/employees/{id}/remove_skill/** + - Удаление навыка у сотрудника + - Body: `{"skill_id": 3}` или `{"skill_name": "Python"}` + - Требует permission: `employees.manage_employee_skills` + +5. **GET /api/v1/employees/{id}/ldap-info/** + - Получение LDAP информации о сотруднике + - Query params: `?force_refresh=true` + - Требует permission: `employees.view_ldap_info` + - Возвращает: dn, groups, department_dn, sync статус + +6. **GET /api/v1/employees/export-excel/** + - Экспорт списка сотрудников в Excel + - Query params: фильтры, поиск + - Возвращает: Excel файл + +7. **PATCH /api/v1/employees/me/** + - Обновление собственного профиля + - Любой аутентифицированный пользователь + +#### EmployeeActionViewSet (`/api/v1/employee-actions/`): + +1. **GET /api/v1/employee-actions/** - список всех кадровых действий +2. **POST /api/v1/employee-actions/** - создание кадрового действия +3. **GET /api/v1/employee-actions/{id}/** - детали действия +4. **PATCH /api/v1/employee-actions/{id}/** - обновление +5. **DELETE /api/v1/employee-actions/{id}/** - удаление + +#### DepartmentRoleViewSet (`/api/v1/department-roles/`): + +1. **GET /api/v1/department-roles/** - список ролей в отделах +2. **POST /api/v1/department-roles/** - создание роли +3. **GET /api/v1/department-roles/{id}/** - детали роли +4. **PATCH /api/v1/department-roles/{id}/** - обновление +5. **DELETE /api/v1/department-roles/{id}/** - удаление +6. **GET /api/v1/department-roles/my_roles/** - роли текущего пользователя +7. **GET /api/v1/department-roles/{id}/employees/** - сотрудники с этой ролью +8. **POST /api/v1/department-roles/{id}/assign/** - назначить роль сотрудникам +9. **GET /api/v1/department-roles/{id}/permissions/** - права роли +10. **POST /api/v1/department-roles/{id}/set_permissions/** - установить права + +#### SkillViewSet дополнительные endpoints: + +1. **PUT /api/v1/skills/{id}/** - полное обновление навыка +2. **DELETE /api/v1/skills/{id}/** - удаление навыка +3. **POST /api/v1/skills/bulk_create/** - массовое создание навыков + - Body: `{"skills": ["Python", "JavaScript", "Docker"]}` +4. **POST /api/v1/skills/merge/** - объединение навыков + - Body: `{"source_id": 1, "target_id": 2}` + +#### GroupViewSet дополнительные endpoints: + +1. **PUT /api/v1/groups/{id}/** - полное обновление группы +2. **DELETE /api/v1/groups/{id}/** - удаление группы +3. **GET /api/v1/groups/{id}/permissions/** - права группы +4. **POST /api/v1/groups/{id}/set-permissions/** - установить права + - Body: `{"permission_ids": [1, 2, 3]}` +5. **POST /api/v1/groups/{id}/add-permissions/** - добавить права + - Body: `{"permission_ids": [4, 5]}` +6. **POST /api/v1/groups/{id}/remove-permissions/** - удалить права + - Body: `{"permission_ids": [1]}` +7. **POST /api/v1/groups/{id}/sync/** - синхронизация с LDAP +8. **POST /api/v1/groups/{id}/set-description/** - установить описание + - Body: `{"description": "..."}` +9. **GET /api/v1/groups/{id}/employees/** - участники группы +10. **POST /api/v1/groups/{id}/add-members/** - добавить участников + - Body: `{"employee_ids": [1, 2, 3]}` +11. **POST /api/v1/groups/{id}/remove-members/** - удалить участников + - Body: `{"employee_ids": [1]}` +12. **POST /api/v1/groups/{id}/replace-members/** - заменить всех участников + - Body: `{"employee_ids": [1, 2, 3]}` + +#### Auth endpoints: + +1. **POST /api/v1/auth/register/** - ✅ уже есть (но возвращает 400) +2. **POST /api/v1/auth/resend-email/** - повторная отправка письма верификации + - Body: `{"email": "user@example.com"}` +3. **POST /api/v1/auth/verify-email/** - подтверждение email + - Body: `{"code": "123456", "email": "user@example.com"}` + +#### DepartmentViewSet дополнительные: + +1. **PUT /api/v1/departments/{id}/** - полное обновление +2. **DELETE /api/v1/departments/{id}/** - удаление +3. **PATCH /api/v1/departments/{id}/** - частичное обновление + +#### PositionViewSet дополнительные: + +1. **GET /api/v1/positions/{id}/** - детали должности +2. **PUT /api/v1/positions/{id}/** - полное обновление +3. **PATCH /api/v1/positions/{id}/** - частичное обновление +4. **DELETE /api/v1/positions/{id}/** - удаление + +## Итого: + +- **Покрыто**: ~15 endpoints +- **Не покрыто**: ~50+ endpoints +- **Покрытие**: ~23% + +## Рекомендации: + +1. **Приоритет 1** (критичные базовые операции): + - POST /employees/ (create) + - DELETE /employees/{id}/ + - PATCH /employees/me/ + - POST /employees/{id}/add_skill/ + - POST /employees/{id}/remove_skill/ + +2. **Приоритет 2** (LDAP и расширенные функции): + - GET /employees/{id}/ldap-info/ + - GET /employees/export-excel/ + - POST /auth/verify-email/ + - POST /auth/resend-email/ + +3. **Приоритет 3** (управление ролями и группами): + - DepartmentRoleViewSet все endpoints + - GroupViewSet custom actions + - EmployeeActionViewSet + +4. **Приоритет 4** (CRUD для вспомогательных сущностей): + - Skills CRUD + - Positions CRUD + - Departments CRUD