From 78dacedd185e7efea9347e18ca6feb177e6028b0 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 10 Aug 2026 16:16:13 +0300 Subject: [PATCH] Add project invitation candidate search --- docs/project-invitations-workspace-api.md | 39 ++- .../test_project_invitation_candidates_api.py | 280 ++++++++++++++++++ invites/throttling.py | 10 + invites/workspace_selectors.py | 91 ++++++ invites/workspace_serializers.py | 32 ++ invites/workspace_views.py | 25 ++ projects/urls.py | 6 + 7 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 invites/tests/test_project_invitation_candidates_api.py create mode 100644 invites/throttling.py create mode 100644 invites/workspace_selectors.py diff --git a/docs/project-invitations-workspace-api.md b/docs/project-invitations-workspace-api.md index 6374c0e6..55274bd2 100644 --- a/docs/project-invitations-workspace-api.md +++ b/docs/project-invitations-workspace-api.md @@ -95,6 +95,41 @@ GET /projects/workspace/invitations/incoming/ Возвращает только историю текущего получателя; pending-приглашения идут первыми. +### Поиск кандидатов + +```http +GET /projects//workspace/invitations/candidates/?q= +``` + +Поиск доступен лидеру Project, staff и superuser. `q` обязателен, после удаления +пробелов должен содержать от 3 до 100 символов. Поиск регистронезависимо +проверяет имя, фамилию, полное имя в обоих порядках и начало email, но email в +ответ не включает. Результат ограничен 20 записями и стабильно сортируется по +фамилии, имени и id. + +Пример ответа: + +```json +[ + { + "id": 42, + "display_name": "Иван Петров", + "avatar": null + } +] +``` + +Из результата исключаются неактивные пользователи, лидер и текущие +`Collaborator`, а также получатели действующих pending-приглашений. Отклоненные +и отозванные записи остаются историей и повторный поиск не блокируют. Если +legacy Project напрямую связан с `PartnerProgramProject`, кандидат должен быть +участником этой программы через `PartnerProgramUserProfile`. + +Endpoint не создает и не резервирует приглашение. `POST` повторно проверяет все +условия в транзакционном service, поэтому устаревший результат поиска не +позволяет обойти права или eligibility. Для поиска действует отдельный throttle +scope `project_invitation_candidate_search` со скоростью `20/min`. + ### Решение и отзыв ```http @@ -150,6 +185,7 @@ Project, staff или superuser. | Действие | Лидер | Collaborator | Получатель | Посторонний | Staff | |---|---:|---:|---:|---:|---:| | Список проекта | Да | Нет | Нет | Нет | Да | +| Поиск кандидатов | Да | Нет | Нет | Нет | Да | | Создание | Да | Нет | Нет | Нет | Да | | Входящие | Только свои | Только свои | Только свои | Только свои | Только свои | | Accept/decline | Только если получатель | Только если получатель | Да | Нет | Только если получатель | @@ -171,7 +207,8 @@ Project, staff или superuser. ## Ограничения DEV-079.1 - приглашаются только существующие активные пользователи по `recipient_id`; -- отдельный безопасный поиск кандидатов не добавлен; +- поиск кандидатов возвращает только `id`, `display_name` и `avatar` и не + заменяет серверную проверку при создании приглашения; - email, уведомления, invite links и expiration отсутствуют; - React UI относится к DEV-079.2; - Angular продолжает использовать legacy `/invites/`; diff --git a/invites/tests/test_project_invitation_candidates_api.py b/invites/tests/test_project_invitation_candidates_api.py new file mode 100644 index 00000000..6c2fa8b3 --- /dev/null +++ b/invites/tests/test_project_invitation_candidates_api.py @@ -0,0 +1,280 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_project, + create_user, + link_project_to_program, +) +from invites.throttling import ProjectInvitationCandidateSearchScopedRateThrottle +from projects.models import Collaborator + + +class ProjectInvitationCandidateAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.leader = self.create_named_user("candidate-leader", "Лидер", "Проекта") + self.collaborator = self.create_named_user( + "candidate-collaborator", "Участник", "Проекта" + ) + self.outsider = self.create_named_user( + "candidate-outsider", "Посторонний", "Пользователь" + ) + self.staff = self.create_named_user( + "candidate-staff", "Администратор", "Платформы", is_staff=True + ) + self.superuser = self.create_named_user( + "candidate-superuser", "Главный", "Администратор" + ) + self.superuser.is_superuser = True + self.superuser.save(update_fields=["is_superuser"]) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + add_collaborator(project=self.project, user=self.collaborator) + + @staticmethod + def create_named_user( + prefix, + first_name="Иван", + last_name="Петров", + *, + is_staff=False, + is_active=True, + ): + user = create_user(prefix=prefix, is_staff=is_staff) + user.first_name = first_name + user.last_name = last_name + user.is_active = is_active + user.save(update_fields=["first_name", "last_name", "is_active"]) + return user + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def candidates_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/invitations/candidates/" + + def search(self, query="петров", *, project=None): + return self.client.get(self.candidates_url(project), {"q": query}) + + def test_endpoint_requires_authentication(self): + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_leader_staff_and_superuser_can_search(self): + candidate = self.create_named_user("candidate-authorized", "Иван", "Петров") + + for actor in (self.leader, self.staff, self.superuser): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.search() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [candidate.pk], + ) + + def test_collaborator_gets_403_and_private_project_is_hidden_from_outsider(self): + self.authenticate(self.collaborator) + collaborator_response = self.search() + self.authenticate(self.outsider) + outsider_response = self.search() + + self.assertEqual(collaborator_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(outsider_response.status_code, status.HTTP_404_NOT_FOUND) + + def test_query_is_required_trimmed_and_has_length_limits(self): + self.authenticate(self.leader) + for params in ({}, {"q": ""}, {"q": " аб "}, {"q": "x" * 101}): + with self.subTest(params=params): + response = self.client.get(self.candidates_url(), params) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("q", response.data) + + candidate = self.create_named_user("candidate-trimmed", "Анна", "Смирнова") + response = self.search(" СМирнова ") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [candidate.pk]) + + def test_searches_by_names_both_full_name_orders_and_email_prefix(self): + candidate = self.create_named_user("candidate-searchable", "Иван", "Петров") + email_prefix = candidate.email.split("@", maxsplit=1)[0] + self.authenticate(self.leader) + + for query in ( + "иван", + "петров", + "иван петров", + "петров иван", + email_prefix, + ): + with self.subTest(query=query): + response = self.search(query) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [candidate.pk], + ) + + def test_response_contains_only_safe_profile_fields(self): + candidate = self.create_named_user("candidate-safe-response", "Иван", "Петров") + candidate.avatar = "https://example.com/avatar.png" + candidate.save(update_fields=["avatar"]) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + [ + { + "id": candidate.pk, + "display_name": "Иван Петров", + "avatar": "https://example.com/avatar.png", + } + ], + ) + self.assertNotIn("email", response.data[0]) + + def test_inactive_leader_collaborator_and_pending_recipient_are_excluded(self): + inactive = self.create_named_user("candidate-inactive", is_active=False) + pending = self.create_named_user("candidate-pending") + Invite.objects.create( + project=self.project, + user=pending, + invited_by=self.leader, + ) + self.authenticate(self.leader) + + response = self.search("проект") + pending_response = self.search("петров") + + result_ids = {item["id"] for item in response.data + pending_response.data} + self.assertNotIn(inactive.pk, result_ids) + self.assertNotIn(self.leader.pk, result_ids) + self.assertNotIn(self.collaborator.pk, result_ids) + self.assertNotIn(pending.pk, result_ids) + + def test_declined_and_revoked_invitation_history_does_not_block_candidate(self): + declined = self.create_named_user("candidate-declined") + revoked = self.create_named_user("candidate-revoked") + Invite.objects.create( + project=self.project, + user=declined, + invited_by=self.leader, + is_accepted=False, + ) + Invite.objects.create( + project=self.project, + user=revoked, + invited_by=self.leader, + is_revoked=True, + ) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {declined.pk, revoked.pk}, + ) + + def test_linked_program_limits_candidates_to_program_members(self): + member = self.create_named_user("candidate-program-member") + non_member = self.create_named_user("candidate-program-outsider") + program = link_project_to_program(project=self.project) + add_user_to_program(user=member, program=program) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [member.pk]) + self.assertNotIn(non_member.pk, [item["id"] for item in response.data]) + + def test_results_are_stable_limited_to_twenty_and_do_not_write(self): + candidates = [ + self.create_named_user( + f"candidate-limit-{index}", + "Одинаковое", + "Имя", + ) + for index in range(22) + ] + before = ( + Invite.objects.count(), + Collaborator.objects.count(), + ) + self.authenticate(self.leader) + + first = self.search("одинаковое") + second = self.search("одинаковое") + + expected_ids = [candidate.pk for candidate in candidates[:20]] + self.assertEqual([item["id"] for item in first.data], expected_ids) + self.assertEqual([item["id"] for item in second.data], expected_ids) + self.assertEqual( + (Invite.objects.count(), Collaborator.objects.count()), + before, + ) + + def test_candidate_list_has_bounded_query_count(self): + for index in range(8): + self.create_named_user(f"candidate-query-{index}") + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as queries: + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 8) + self.assertLessEqual(len(queries), 4) + + def test_post_revalidates_eligibility_after_search(self): + candidate = self.create_named_user("candidate-revalidated") + self.authenticate(self.leader) + self.assertEqual(self.search().status_code, status.HTTP_200_OK) + add_collaborator(project=self.project, user=candidate) + + response = self.client.post( + f"/projects/{self.project.pk}/workspace/invitations/", + {"recipient_id": candidate.pk}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("recipient_id", response.data) + self.assertFalse( + Invite.objects.filter(project=self.project, user=candidate).exists() + ) + + @patch.object( + ProjectInvitationCandidateSearchScopedRateThrottle, + "rate", + "1/min", + ) + def test_search_has_dedicated_throttle(self): + self.authenticate(self.leader) + + first = self.search() + second = self.search() + + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(second.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/invites/throttling.py b/invites/throttling.py new file mode 100644 index 00000000..493f0e8e --- /dev/null +++ b/invites/throttling.py @@ -0,0 +1,10 @@ +from rest_framework.throttling import ScopedRateThrottle + + +class ProjectInvitationCandidateSearchScopedRateThrottle(ScopedRateThrottle): + """Ограничивает scoped-поиск без включения глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate diff --git a/invites/workspace_selectors.py b/invites/workspace_selectors.py new file mode 100644 index 00000000..3e18df1e --- /dev/null +++ b/invites/workspace_selectors.py @@ -0,0 +1,91 @@ +from django.contrib.auth import get_user_model +from django.db.models import Exists, OuterRef, Q, Value +from django.db.models.functions import Concat, Lower + +from invites.models import Invite +from partner_programs.models import PartnerProgramUserProfile +from projects.models import Collaborator, Project + +User = get_user_model() + +PROJECT_INVITATION_CANDIDATE_LIMIT = 20 + + +def get_project_invitation_candidates(*, project: Project, query: str): + """Возвращает безопасный ограниченный queryset кандидатов для Project. + + Selector только помогает лидеру выбрать пользователя. Сервис создания + приглашения повторно проверяет eligibility, поэтому изменение состава или + регистрации между поиском и POST не позволяет обойти бизнес-правила. + """ + collaborators = Collaborator.objects.filter( + project_id=project.pk, + user_id=OuterRef("pk"), + ) + pending_invitations = Invite.objects.filter( + project_id=project.pk, + user_id=OuterRef("pk"), + is_accepted__isnull=True, + is_revoked=False, + ) + + normalized_query = query.strip() + # В production PostgreSQL `icontains` регистронезависим для Unicode. Набор + # вариантов сохраняет такой же результат в локальной SQLite test-среде. + query_variants = dict.fromkeys( + ( + normalized_query, + normalized_query.casefold(), + normalized_query.lower(), + normalized_query.upper(), + normalized_query.title(), + normalized_query.capitalize(), + ) + ) + search_filter = Q() + for query_variant in query_variants: + search_filter |= ( + Q(first_name__icontains=query_variant) + | Q(last_name__icontains=query_variant) + | Q(candidate_full_name__icontains=query_variant) + | Q(candidate_reverse_name__icontains=query_variant) + | Q(email__istartswith=query_variant) + ) + + candidates = User.objects.annotate( + candidate_full_name=Concat("first_name", Value(" "), "last_name"), + candidate_reverse_name=Concat("last_name", Value(" "), "first_name"), + candidate_is_collaborator=Exists(collaborators), + candidate_has_pending_invitation=Exists(pending_invitations), + ).filter( + is_active=True, + candidate_is_collaborator=False, + candidate_has_pending_invitation=False, + ) + + # Legacy Project считается напрямую связанным максимум с одной программой. + # Повторяем invariant сервиса создания приглашения, не расширяя его контракт. + program_id = ( + project.program_links.order_by("pk") + .values_list("partner_program_id", flat=True) + .first() + ) + if program_id is not None: + program_members = PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + user_id=OuterRef("pk"), + ) + candidates = candidates.annotate( + candidate_is_program_member=Exists(program_members) + ).filter(candidate_is_program_member=True) + + # Минимальная длина query, DB-limit и project-scoped исключения не дают + # использовать endpoint как глобальный каталог пользователей платформы. + return ( + candidates.exclude(pk=project.leader_id) + .filter(search_filter) + .only("id", "first_name", "last_name", "avatar") + .order_by(Lower("last_name"), Lower("first_name"), "pk")[ + :PROJECT_INVITATION_CANDIDATE_LIMIT + ] + ) diff --git a/invites/workspace_serializers.py b/invites/workspace_serializers.py index 20ce857f..5b59c39a 100644 --- a/invites/workspace_serializers.py +++ b/invites/workspace_serializers.py @@ -5,6 +5,38 @@ from users.models import CustomUser +class ProjectInvitationCandidateQuerySerializer(serializers.Serializer): + """Проверяет обязательный узкий поисковый запрос кандидатов.""" + + q = serializers.CharField( # noqa: VNE001 — имя закреплено API-контрактом. + required=True, + trim_whitespace=True, + min_length=3, + max_length=100, + error_messages={ + "required": "Укажите поисковый запрос.", + "blank": "Укажите поисковый запрос.", + "min_length": "Введите не менее 3 символов.", + "max_length": "Введите не более 100 символов.", + }, + ) + + +class ProjectInvitationCandidateSerializer(serializers.ModelSerializer): + """Возвращает только публичный минимум профиля кандидата.""" + + display_name = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ("id", "display_name", "avatar") + read_only_fields = fields + + def get_display_name(self, user: CustomUser) -> str: + """Формирует имя без fallback на закрытый email пользователя.""" + return user.get_full_name().strip() + + class ProjectInvitationCreateSerializer(serializers.Serializer): """Принимает пользователя и необязательные данные будущего Collaborator.""" diff --git a/invites/workspace_views.py b/invites/workspace_views.py index 1a4e5785..718154f1 100644 --- a/invites/workspace_views.py +++ b/invites/workspace_views.py @@ -13,11 +13,15 @@ from rest_framework.views import APIView from invites.models import Invite +from invites.throttling import ProjectInvitationCandidateSearchScopedRateThrottle from invites.workspace_serializers import ( ProjectInvitationActionSerializer, + ProjectInvitationCandidateQuerySerializer, + ProjectInvitationCandidateSerializer, ProjectInvitationCreateSerializer, ProjectInvitationSerializer, ) +from invites.workspace_selectors import get_project_invitation_candidates from invites.workspace_services import ( ProjectInvitationDuplicateError, ProjectInvitationNotOwnedError, @@ -86,6 +90,27 @@ def _validate_empty_action_payload(data) -> None: serializer.is_valid(raise_exception=True) +class ProjectInvitationCandidateSearchView(APIView): + """Ищет кандидатов только внутри доступного лидеру Project.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [ProjectInvitationCandidateSearchScopedRateThrottle] + throttle_scope = "project_invitation_candidate_search" + + def get(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + query_serializer = ProjectInvitationCandidateQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + candidates = get_project_invitation_candidates( + project=project, + query=query_serializer.validated_data["q"], + ) + return Response(ProjectInvitationCandidateSerializer(candidates, many=True).data) + + class ProjectInvitationListCreateView(APIView): """Возвращает историю приглашений Project и создает pending-запись.""" diff --git a/projects/urls.py b/projects/urls.py index 995b7006..076dd5ed 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -3,6 +3,7 @@ from invites.workspace_views import ( IncomingProjectInvitationListView, ProjectInvitationAcceptView, + ProjectInvitationCandidateSearchView, ProjectInvitationDeclineView, ProjectInvitationListCreateView, ProjectInvitationRevokeView, @@ -109,6 +110,11 @@ ProjectInvitationListCreateView.as_view(), name="workspace-invitations", ), + path( + "/workspace/invitations/candidates/", + ProjectInvitationCandidateSearchView.as_view(), + name="workspace-invitation-candidates", + ), path( "/workspace/invitations//revoke/", ProjectInvitationRevokeView.as_view(),