docs: добавление документации по тестированию и оптимизации
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 звезд
This commit is contained in:
parent
c819196bb9
commit
d8a48d4c33
818
backend/docs/reports/DB_QUERIES_ANALYSIS.md
Normal file
818
backend/docs/reports/DB_QUERIES_ANALYSIS.md
Normal file
@ -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)
|
||||
|
||||
Код написан с пониманием оптимизации БД, но есть несколько критических мест требующих доработки.
|
||||
408
docs/guides/API_TESTING_GUIDE.md
Normal file
408
docs/guides/API_TESTING_GUIDE.md
Normal file
@ -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/)
|
||||
172
docs/reports/API_EMPLOYEES_COVERAGE.md
Normal file
172
docs/reports/API_EMPLOYEES_COVERAGE.md
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user