From a027d1164140c59920887625a148edc0ec268aa4 Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 22 Jul 2026 19:20:27 +0300 Subject: [PATCH] Add team invite candidate search --- docs/case-championship-domain.md | 5 +- docs/team-invites-api.md | 35 +- docs/team-permissions-api.md | 11 +- partner_programs/application_urls.py | 10 +- partner_programs/serializers/__init__.py | 2 + partner_programs/serializers/team_invites.py | 17 + partner_programs/services/__init__.py | 2 + partner_programs/services/team_invites.py | 123 +++++ partner_programs/team_invite_views.py | 39 +- .../tests/test_team_invite_candidates.py | 502 ++++++++++++++++++ partner_programs/throttling.py | 9 + 11 files changed, 747 insertions(+), 8 deletions(-) create mode 100644 partner_programs/tests/test_team_invite_candidates.py diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index d405d743..af3de5f0 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -633,7 +633,7 @@ Registration. | Application → Project | Nullable FK, reuse, owner validation, immutable после draft | Ручная связь | Prefill mapping и `project_snapshot` | Автоматически Project не создается | | Team | One-to-one Application, name, captain, timestamps, validation/admin, creation/invariant/management services и public API | Add member только через TeamInvite accept | Email/link invite integration | Mutation только draft до application deadline | | TeamMember | Roles/statuses, invited_by, joined_at, constraints, validation, admin, read/leave/remove/transfer | invited — только историческая заготовка | Organizer actions | Не переиспользует Project Collaborator | -| TeamInvite | Platform-user model/admin, history, create/my/accept/decline/revoke API, conflicts/capacity/locking/throttle | Только существующий `user_id` | Token/email/link/expiry/notifications | Pending не дает membership или read-доступ | +| TeamInvite | Platform-user model/admin, scoped candidate search, history, create/my/accept/decline/revoke API, conflicts/capacity/locking/throttle | Только существующий `user_id` | Token/email/link/expiry/notifications | Search не резервирует место; pending не дает membership или read-доступ | | Notification | Email и chat WebSocket infrastructure | Mailing logs не являются inbox | Доменная Notification и пользовательский центр | Не входит в первый Team PR | | Evaluation | `Criteria`, `ProjectScore`, `ProjectExpertAssignment` для legacy Project | Эксперты и распределенное оценивание проекта | Evaluation по Submission | Нельзя смешивать с ProjectScore без миграции | | Result | Legacy scores и пользовательские достижения существуют отдельно | Нет единого результата заявки | Result/ranking/publication contract | Требует решения об источнике итогов | @@ -659,6 +659,7 @@ Registration. | Team | `POST /applications//team/leave/` | Покинуть draft Team обычному accepted member | | Team | `POST /applications//team/members//remove/` | Исключить обычного member капитаном/staff | | Team | `POST /applications//team/transfer-captain/` | Атомарно передать капитанство и ownership Application | +| TeamInvite | `GET /applications//team/invite-candidates/?q=...` | Найти до 20 зарегистрированных и потенциально доступных кандидатов без раскрытия email | | TeamInvite | `GET/POST /applications//team/invites/` | История и создание platform-user приглашения капитаном/staff | | TeamInvite | `GET /team-invites/my/` | Приглашения текущего пользователя, pending первыми | | TeamInvite | `POST /team-invites//accept/` | Принять приглашение и создать/восстановить accepted TeamMember | @@ -693,6 +694,8 @@ Registration. - Submission tests покрывают доступ, допустимые статусы Application, автозаполнение Program/submitter, версии, immutable fields, переходы и throttle; +- TeamInvite candidate tests покрывают scoped search, eligibility, безопасный + response, permission/state boundaries, limit и отдельный throttle; - registration и legacy project tests отдельно покрывают их дедлайны и права. ## 14. Gap analysis diff --git a/docs/team-invites-api.md b/docs/team-invites-api.md index a64808f1..15ce1053 100644 --- a/docs/team-invites-api.md +++ b/docs/team-invites-api.md @@ -29,6 +29,7 @@ | Method | Endpoint | Доступ | Назначение | |---|---|---|---| +| GET | `/applications//team/invite-candidates/?q=...` | captain, staff | Найти потенциальных кандидатов | | GET | `/applications//team/invites/` | captain, staff | История приглашений Team | | POST | `/applications//team/invites/` | captain, staff | Создать pending по `{"user_id": id}` | | GET | `/team-invites/my/` | invitee | Собственные приглашения, pending первыми | @@ -57,6 +58,32 @@ Accepted-состав вместе с pending-приглашениями не д `team_max_size`. Повторный запрос для уже существующего pending идемпотентно возвращает его, не создавая новую строку. +## Поиск кандидатов + +Поиск scoped к конкретной Team и не является глобальным каталогом пользователей. +Параметр `q` обязателен, очищается от пробелов и должен содержать от 3 до 100 +символов. Поиск выполняется по имени, фамилии, обоим порядкам полного имени и +email prefix без учета обычных вариантов регистра. Email используется только +в ORM-фильтре и никогда не включается в response. + +Selector возвращает не более 20 активных пользователей со стабильной +сортировкой. Каждый кандидат должен иметь Registration этой Program и не иметь: + +- accepted membership или капитанства в текущей Team; +- pending TeamInvite в текущую Team; +- другой активной собственной Application в Program; +- accepted membership другой активной Team в Program. + +Исторические declined/revoked invites, removed/left memberships и терминальные +Application не исключают кандидата. ORM использует `Exists`-подзапросы без +загрузки регистраций в Python, N+1 и размножающих строки join-ов. + +Search не создает TeamInvite, TeamMember и не резервирует место. Перед поиском +проверяются draft, application deadline и свободное место с учетом accepted + +pending. После выбора `POST .../team/invites/` заново выполняет все проверки +под блокировкой Program, поэтому устаревший результат поиска нельзя использовать +для обхода domain invariants. + ## Accept, decline и revoke Все переходы блокируют строки в порядке Program → Application → Team → @@ -90,8 +117,10 @@ pending не мешает отправить Application. - общие `application_not_editable` и `application_deadline_passed`. Mutation endpoints имеют независимые локальные scopes -`team_invite_create/accept/decline/revoke` с rate `20/min`. GET endpoints не -ограничиваются этим throttle; глобальные DRF settings не менялись. +`team_invite_create/accept/decline/revoke` с rate `20/min`. Scoped GET-поиск +использует отдельный `team_invite_candidate_search` с тем же rate. Остальные +GET endpoints этим throttle не ограничиваются; глобальные DRF settings не +менялись. ## Ограничения MVP @@ -100,7 +129,7 @@ Mutation endpoints имеют независимые локальные scopes - приглашение по email, token или ссылке; - expiry; - email и in-app notifications; -- поиск пользователей; +- глобальный каталог и поиск пользователей вне контекста Team; - organizer/manager mutation; - frontend flow. diff --git a/docs/team-permissions-api.md b/docs/team-permissions-api.md index 026c229a..ee593ead 100644 --- a/docs/team-permissions-api.md +++ b/docs/team-permissions-api.md @@ -36,6 +36,12 @@ Application, Team или Submission. - `POST /applications//team/transfer-captain/` — передать роль, body: `{"member_id": }`. +Капитан и staff также могут использовать scoped-поиск кандидатов +`GET /applications//team/invite-candidates/?q=...`. Accepted +member и manager сохраняют read-only доступ к Team, но получают 403 на поиск и +список приглашений; постороннему существование Team скрывается ответом 404. +Контракт поиска и правила eligibility описаны в `docs/team-invites-api.md`. + Team response содержит безопасный профиль пользователя (`id`, `display_name`, `avatar`) без email, телефона и закрытых полей. Состав сортируется: капитан, accepted members, исторические статусы, затем дата/id. @@ -119,8 +125,9 @@ Rate равен `20/min` на scope и пользователя/IP. Throttle л ## Ограничения MVP В API намеренно отсутствуют прямой add member, token/link/email invites, -expiry, уведомления и поиск пользователей. Базовый TeamInvite по существующему -`user_id` реализован отдельно. Manager получает только read-access; +expiry, уведомления и глобальный каталог пользователей. Базовый TeamInvite по +существующему `user_id` и scoped-поиск кандидатов реализованы отдельно. Manager +получает только read-access; organizer review, approve/reject/return, изменение Submission lifecycle и expert permissions остаются отдельными PR. Frontend и legacy Project flow не затрагиваются. diff --git a/partner_programs/application_urls.py b/partner_programs/application_urls.py index cd496fc5..b8df2f7c 100644 --- a/partner_programs/application_urls.py +++ b/partner_programs/application_urls.py @@ -6,7 +6,10 @@ ApplicationWithdrawView, ) from partner_programs.submission_views import ApplicationSubmissionListCreateView -from partner_programs.team_invite_views import TeamInviteListCreateView +from partner_programs.team_invite_views import ( + TeamInviteCandidateSearchView, + TeamInviteListCreateView, +) from partner_programs.team_views import ( TeamDetailView, TeamLeaveView, @@ -17,6 +20,11 @@ app_name = "applications" urlpatterns = [ + path( + "/team/invite-candidates/", + TeamInviteCandidateSearchView.as_view(), + name="team-invite-candidate-search", + ), path( "/team/invites/", TeamInviteListCreateView.as_view(), diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 596e1bab..060f6d3f 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -19,6 +19,7 @@ from .submissions import SubmissionSerializer from .team_invites import ( MyTeamInviteSerializer, + TeamInviteCandidateQuerySerializer, TeamInviteCreateSerializer, TeamInviteSerializer, ) @@ -50,6 +51,7 @@ "ProgramProjectFilterRequestSerializer", "SubmissionSerializer", "MyTeamInviteSerializer", + "TeamInviteCandidateQuerySerializer", "TeamInviteCreateSerializer", "TeamInviteSerializer", "TeamMemberSerializer", diff --git a/partner_programs/serializers/team_invites.py b/partner_programs/serializers/team_invites.py index b6f2ae7b..73abc3d0 100644 --- a/partner_programs/serializers/team_invites.py +++ b/partner_programs/serializers/team_invites.py @@ -21,6 +21,23 @@ def validate(self, attrs): return attrs +class TeamInviteCandidateQuerySerializer(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 TeamInviteSerializer(serializers.ModelSerializer): """Безопасное представление приглашения для капитана и staff.""" diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index c03d968f..ddc830eb 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -45,6 +45,7 @@ accept_team_invite, create_team_invite, decline_team_invite, + get_team_invite_candidates, revoke_team_invite, ) from partner_programs.services.exports import ( @@ -125,6 +126,7 @@ "create_user_and_register_to_program", "get_filterable_program_fields", "get_filtered_program_project_links", + "get_team_invite_candidates", "decline_team_invite", "prepare_project_scores_export_data", "publish_finished_program_projects", diff --git a/partner_programs/services/team_invites.py b/partner_programs/services/team_invites.py index f9c7d17a..1835c525 100644 --- a/partner_programs/services/team_invites.py +++ b/partner_programs/services/team_invites.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction +from django.db.models import Exists, OuterRef, Q, Value +from django.db.models.functions import Concat, Lower from django.utils import timezone from partner_programs.models import ( @@ -23,6 +25,8 @@ User = get_user_model() +TEAM_INVITE_CANDIDATE_LIMIT = 20 + class TeamInvitePermissionError(ApplicationTeamServiceError): code = "team_invite_permission_denied" @@ -171,6 +175,125 @@ def _require_accept_capacity(*, team: Team, program: PartnerProgram) -> None: raise TeamInviteCapacityReachedError() +def get_team_invite_candidates( + *, + team: Team, + actor: User, + query: str, +): + """Возвращает ограниченный queryset потенциальных участников Team. + + Eligibility выражена через Exists-подзапросы: связанные Application, + TeamMember и TeamInvite не размножают строки пользователя и не требуют + широкого `distinct()`. Selector не резервирует место; create service + повторяет проверки под блокировкой Program. + """ + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + + application = team.application + program = application.program + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + raise TeamInviteTargetInvalidError( + "Поиск кандидатов доступен только для командной заявки.", + field="team", + ) + _require_mutable_invites(application=application, program=program) + + accepted_count = TeamMember.objects.filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ).count() + pending_count = TeamInvite.objects.filter( + team=team, + status=TeamInvite.STATUS_PENDING, + ).count() + if ( + program.team_max_size is None + or accepted_count + pending_count >= program.team_max_size + ): + raise TeamInviteCapacityReachedError() + + registrations = PartnerProgramUserProfile.objects.filter( + partner_program=program, + user_id=OuterRef("pk"), + ) + pending_current_invites = TeamInvite.objects.filter( + team=team, + user_id=OuterRef("pk"), + status=TeamInvite.STATUS_PENDING, + ) + active_owned_applications = Application.objects.filter( + program=program, + user_id=OuterRef("pk"), + status__in=Application.ACTIVE_STATUSES, + ) + active_team_memberships = TeamMember.objects.filter( + user_id=OuterRef("pk"), + status=TeamMember.STATUS_ACCEPTED, + team__application__program=program, + team__application__status__in=Application.ACTIVE_STATUSES, + ) + + normalized_query = query.strip() + # SQLite не нормализует регистр кириллицы для ILIKE так же, как production + # PostgreSQL. Небольшой набор Unicode-вариантов сохраняет ORM-фильтрацию и + # одинаковый контракт для обычного lower/UPPER/Title ввода. + 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) + ) + + # Минимальная длина query и жесткий limit не позволяют использовать этот + # scoped endpoint как глобальное перечисление пользователей платформы. + return ( + User.objects.annotate( + candidate_full_name=Concat( + "first_name", + Value(" "), + "last_name", + ), + candidate_reverse_name=Concat( + "last_name", + Value(" "), + "first_name", + ), + candidate_is_registered=Exists(registrations), + candidate_has_pending_invite=Exists(pending_current_invites), + candidate_has_active_application=Exists(active_owned_applications), + candidate_has_active_membership=Exists(active_team_memberships), + ) + .filter( + is_active=True, + candidate_is_registered=True, + candidate_has_pending_invite=False, + candidate_has_active_application=False, + candidate_has_active_membership=False, + ) + .exclude(pk=team.captain_id) + .filter(search_filter) + .only("id", "first_name", "last_name", "avatar") + .order_by(Lower("last_name"), Lower("first_name"), "pk")[ + :TEAM_INVITE_CANDIDATE_LIMIT + ] + ) + + def create_team_invite( *, team: Team, diff --git a/partner_programs/team_invite_views.py b/partner_programs/team_invite_views.py index 3fbb4f46..7219b2ed 100644 --- a/partner_programs/team_invite_views.py +++ b/partner_programs/team_invite_views.py @@ -11,8 +11,10 @@ from partner_programs.permissions import can_manage_team, can_view_team from partner_programs.serializers import ( MyTeamInviteSerializer, + TeamInviteCandidateQuerySerializer, TeamInviteCreateSerializer, TeamInviteSerializer, + TeamUserSerializer, ) from partner_programs.services.application_team import ApplicationTeamServiceError from partner_programs.services.team_invites import ( @@ -22,9 +24,13 @@ accept_team_invite, create_team_invite, decline_team_invite, + get_team_invite_candidates, revoke_team_invite, ) -from partner_programs.throttling import TeamInviteMutationScopedRateThrottle +from partner_programs.throttling import ( + TeamInviteCandidateSearchScopedRateThrottle, + TeamInviteMutationScopedRateThrottle, +) User = get_user_model() @@ -119,6 +125,37 @@ def get_throttles(self): return [] +class TeamInviteCandidateSearchView(APIView): + """Ищет безопасные профили кандидатов только внутри управляемой Team.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteCandidateSearchScopedRateThrottle] + throttle_scope = "team_invite_candidate_search" + + def get(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + query_serializer = TeamInviteCandidateQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + try: + candidates = get_team_invite_candidates( + team=team, + actor=request.user, + query=query_serializer.validated_data["q"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response( + TeamUserSerializer( + candidates, + many=True, + context={"request": request}, + ).data + ) + + class MyTeamInviteListView(APIView): """Возвращает только приглашения текущего пользователя: pending первыми.""" diff --git a/partner_programs/tests/test_team_invite_candidates.py b/partner_programs/tests/test_team_invite_candidates.py new file mode 100644 index 00000000..a28e5d4a --- /dev/null +++ b/partner_programs/tests/test_team_invite_candidates.py @@ -0,0 +1,502 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + Team, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + create_or_get_application, +) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInvitePermissionError, + TeamInviteTargetInvalidError, + create_team_invite, + get_team_invite_candidates, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import ( + TeamInviteCandidateSearchScopedRateThrottle, +) + + +class TeamInviteCandidateFixtureMixin: + def setUp(self): + cache.clear() + self.captain = create_user( + prefix="candidate-captain", + first_name="Капитан", + last_name="Команды", + ) + self.accepted_user = create_user( + prefix="candidate-member", + first_name="Принятый", + last_name="Участник", + ) + self.manager = create_user(prefix="candidate-manager") + self.staff = create_user(prefix="candidate-staff", is_staff=True) + self.outsider = create_user(prefix="candidate-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=50, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.accepted_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Поиск кандидатов", + ).application + self.team = self.application.team + self.accepted_member = TeamMember.objects.create( + team=self.team, + user=self.accepted_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + def create_candidate( + self, + *, + first_name="Иван", + last_name="Кандидатов", + register=True, + is_active=True, + prefix="candidate-user", + ): + user = create_user( + prefix=prefix, + first_name=first_name, + last_name=last_name, + is_active=is_active, + ) + if register: + create_program_member(self.program, user=user, data={"secret": "hidden"}) + return user + + def candidate_ids(self, query="кандидат"): + return list( + get_team_invite_candidates( + team=self.team, + actor=self.captain, + query=query, + ).values_list("pk", flat=True) + ) + + def reload_team(self): + self.team = Team.objects.select_related( + "application", + "application__program", + "captain", + ).get(pk=self.team.pk) + + +class TeamInviteCandidateSelectorTests(TeamInviteCandidateFixtureMixin, TestCase): + def test_searches_by_first_name_last_name_and_both_orders(self): + user = self.create_candidate(first_name="Иван", last_name="Петров") + + for query in ("иван", "петров", "иван петров", "петров иван"): + with self.subTest(query=query): + self.assertEqual(self.candidate_ids(query), [user.pk]) + + def test_search_is_case_insensitive_and_supports_email_prefix(self): + user = self.create_candidate(first_name="Анна", last_name="Смирнова") + + self.assertEqual(self.candidate_ids("АННА"), [user.pk]) + self.assertEqual( + self.candidate_ids(user.email.split("@", maxsplit=1)[0]), + [user.pk], + ) + + def test_results_have_stable_case_insensitive_sorting(self): + first = self.create_candidate(first_name="Борис", last_name="Тест Альфа") + second = self.create_candidate(first_name="Анна", last_name="тест альфа") + third = self.create_candidate(first_name="Анна", last_name="Тест Бета") + + first_result = self.candidate_ids("тест") + second_result = self.candidate_ids("ТЕСТ") + + self.assertEqual(first_result, second_result) + self.assertEqual(set(first_result), {first.pk, second.pk, third.pk}) + + def test_related_history_does_not_duplicate_candidate(self): + user = self.create_candidate() + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=TeamInvite.STATUS_DECLINED, + resolved_at=timezone.now(), + ) + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=TeamInvite.STATUS_REVOKED, + resolved_at=timezone.now(), + ) + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_LEFT, + invited_by=self.captain, + ) + + self.assertEqual(self.candidate_ids(), [user.pk]) + + def test_result_count_is_limited_to_twenty(self): + for index in range(25): + self.create_candidate( + first_name=f"Кандидат {str(index).zfill(2)}", + last_name="Лимитов", + prefix="candidate-limit", + ) + + self.assertEqual(len(self.candidate_ids("лимитов")), 20) + + def test_registered_free_user_is_returned(self): + user = self.create_candidate() + + self.assertEqual(self.candidate_ids(), [user.pk]) + + def test_unregistered_and_inactive_users_are_excluded(self): + unregistered = self.create_candidate(register=False) + inactive = self.create_candidate(is_active=False) + + result = self.candidate_ids() + + self.assertNotIn(unregistered.pk, result) + self.assertNotIn(inactive.pk, result) + + def test_captain_and_accepted_member_are_excluded(self): + self.assertNotIn(self.captain.pk, self.candidate_ids("капитан")) + self.assertNotIn(self.accepted_user.pk, self.candidate_ids("принятый")) + + def test_pending_invite_in_current_team_is_excluded(self): + user = self.create_candidate() + create_team_invite( + team=self.team, + actor=self.captain, + target=user, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_active_owned_application_is_excluded(self): + user = self.create_candidate() + Application.objects.create( + program=self.program, + user=user, + created_by=user, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_accepted_membership_in_other_active_team_is_excluded(self): + user = self.create_candidate() + other_captain = self.create_candidate( + first_name="Другой", + last_name="Капитан", + prefix="candidate-other-captain", + ) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Другая команда", + ).application + TeamMember.objects.create( + team=other_application.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_resolved_invites_do_not_exclude_candidate(self): + declined = self.create_candidate(prefix="candidate-declined") + revoked = self.create_candidate(prefix="candidate-revoked") + for user, invite_status in ( + (declined, TeamInvite.STATUS_DECLINED), + (revoked, TeamInvite.STATUS_REVOKED), + ): + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=invite_status, + resolved_at=timezone.now(), + ) + + result = self.candidate_ids() + + self.assertIn(declined.pk, result) + self.assertIn(revoked.pk, result) + + def test_removed_and_left_members_can_be_candidates_again(self): + removed = self.create_candidate(prefix="candidate-removed") + left = self.create_candidate(prefix="candidate-left") + for user, member_status in ( + (removed, TeamMember.STATUS_REMOVED), + (left, TeamMember.STATUS_LEFT), + ): + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=member_status, + invited_by=self.captain, + ) + + result = self.candidate_ids() + + self.assertIn(removed.pk, result) + self.assertIn(left.pk, result) + + def test_terminal_application_does_not_exclude_candidate(self): + user = self.create_candidate() + Application.objects.create( + program=self.program, + user=user, + created_by=user, + status=Application.STATUS_WITHDRAWN, + ) + + self.assertIn(user.pk, self.candidate_ids()) + + def test_selector_enforces_permission_and_team_state(self): + self.create_candidate() + with self.assertRaises(TeamInvitePermissionError): + get_team_invite_candidates( + team=self.team, + actor=self.outsider, + query="кандидат", + ) + + Application.objects.filter(pk=self.application.pk).update( + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL + ) + self.reload_team() + with self.assertRaises(TeamInviteTargetInvalidError): + self.candidate_ids() + + def test_non_draft_deadline_and_capacity_are_rejected(self): + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + self.reload_team() + with self.assertRaises(ApplicationNotEditableError): + self.candidate_ids() + + self.application.status = Application.STATUS_DRAFT + self.application.save(update_fields=["status", "updated_at"]) + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + self.reload_team() + with self.assertRaises(ApplicationDeadlinePassedError): + self.candidate_ids() + + self.program.datetime_application_ends = None + self.program.team_max_size = 2 + self.program.save( + update_fields=["datetime_application_ends", "team_max_size"] + ) + self.reload_team() + with self.assertRaises(TeamInviteCapacityReachedError): + self.candidate_ids() + + def test_search_does_not_bypass_create_service_rechecks(self): + user = self.create_candidate() + self.assertIn(user.pk, self.candidate_ids()) + Application.objects.create( + program=self.program, + user=user, + created_by=user, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + create_team_invite( + team=self.team, + actor=self.captain, + target=user, + ) + + +class TeamInviteCandidateAPITests(TeamInviteCandidateFixtureMixin, TestCase): + def setUp(self): + super().setUp() + self.client = APIClient() + + @property + def url(self): + return f"/applications/{self.application.pk}/team/invite-candidates/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def test_captain_and_staff_can_search(self): + user = self.create_candidate() + for actor in (self.captain, self.staff): + self.authenticate(actor) + with self.subTest(actor=actor): + response = self.client.get(self.url, {"q": "кандидат"}) + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [user.pk]) + + def test_member_and_manager_get_403_outsider_gets_404(self): + for actor in (self.accepted_user, self.manager): + self.authenticate(actor) + with self.subTest(actor=actor): + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 403, + ) + + self.authenticate(self.outsider) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 404, + ) + + invited_user = self.create_candidate(prefix="candidate-invited-user") + create_team_invite( + team=self.team, + actor=self.captain, + target=invited_user, + ) + self.authenticate(invited_user) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 404, + ) + + def test_anonymous_gets_401(self): + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 401, + ) + + def test_query_is_required_trimmed_and_limited(self): + self.authenticate(self.captain) + for params in ({}, {"q": ""}, {"q": " аб "}, {"q": "x" * 101}): + with self.subTest(params=params): + self.assertEqual(self.client.get(self.url, params).status_code, 400) + + user = self.create_candidate(first_name="Иван", last_name="Иванов") + response = self.client.get(self.url, {"q": " ИВАНОВ "}) + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [user.pk]) + + def test_response_contains_only_safe_profile_fields(self): + user = self.create_candidate() + self.authenticate(self.captain) + + response = self.client.get(self.url, {"q": "кандидат"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["id"], user.pk) + self.assertEqual( + set(response.data[0]), + {"id", "display_name", "avatar"}, + ) + for forbidden in ( + "email", + "phone_number", + "birthday", + "partner_program_data", + "application", + "team", + ): + self.assertNotIn(forbidden, response.data[0]) + + def test_non_draft_deadline_and_capacity_return_400(self): + self.authenticate(self.captain) + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + self.application.status = Application.STATUS_DRAFT + self.application.save(update_fields=["status", "updated_at"]) + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + self.program.datetime_application_ends = None + self.program.team_max_size = 3 + self.program.save( + update_fields=["datetime_application_ends", "team_max_size"] + ) + pending_target = self.create_candidate(prefix="candidate-capacity-pending") + create_team_invite( + team=self.team, + actor=self.captain, + target=pending_target, + ) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + def test_individual_application_without_team_returns_404(self): + owner = create_user(prefix="candidate-individual-owner") + create_program_member(self.program, user=owner) + individual = Application.objects.create( + program=self.program, + user=owner, + created_by=owner, + ) + self.authenticate(owner) + + response = self.client.get( + f"/applications/{individual.pk}/team/invite-candidates/", + {"q": "кандидат"}, + ) + + self.assertEqual(response.status_code, 404) + + @patch.object( + TeamInviteCandidateSearchScopedRateThrottle, + "rate", + "1/min", + ) + def test_search_has_dedicated_throttle(self): + self.authenticate(self.captain) + + first = self.client.get(self.url, {"q": "кандидат"}) + second = self.client.get(self.url, {"q": "кандидат"}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py index c9cf95c0..680501a8 100644 --- a/partner_programs/throttling.py +++ b/partner_programs/throttling.py @@ -17,3 +17,12 @@ class TeamInviteMutationScopedRateThrottle(ScopedRateThrottle): def get_rate(self): return self.rate + + +class TeamInviteCandidateSearchScopedRateThrottle(ScopedRateThrottle): + """Ограничивает scoped-поиск кандидатов без глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate