Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/case-championship-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | Требует решения об источнике итогов |
Expand All @@ -659,6 +659,7 @@ Registration.
| Team | `POST /applications/<id>/team/leave/` | Покинуть draft Team обычному accepted member |
| Team | `POST /applications/<id>/team/members/<member_id>/remove/` | Исключить обычного member капитаном/staff |
| Team | `POST /applications/<id>/team/transfer-captain/` | Атомарно передать капитанство и ownership Application |
| TeamInvite | `GET /applications/<id>/team/invite-candidates/?q=...` | Найти до 20 зарегистрированных и потенциально доступных кандидатов без раскрытия email |
| TeamInvite | `GET/POST /applications/<id>/team/invites/` | История и создание platform-user приглашения капитаном/staff |
| TeamInvite | `GET /team-invites/my/` | Приглашения текущего пользователя, pending первыми |
| TeamInvite | `POST /team-invites/<id>/accept/` | Принять приглашение и создать/восстановить accepted TeamMember |
Expand Down Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions docs/team-invites-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

| Method | Endpoint | Доступ | Назначение |
|---|---|---|---|
| GET | `/applications/<application_id>/team/invite-candidates/?q=...` | captain, staff | Найти потенциальных кандидатов |
| GET | `/applications/<application_id>/team/invites/` | captain, staff | История приглашений Team |
| POST | `/applications/<application_id>/team/invites/` | captain, staff | Создать pending по `{"user_id": id}` |
| GET | `/team-invites/my/` | invitee | Собственные приглашения, pending первыми |
Expand Down Expand Up @@ -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 →
Expand Down Expand Up @@ -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

Expand All @@ -100,7 +129,7 @@ Mutation endpoints имеют независимые локальные scopes
- приглашение по email, token или ссылке;
- expiry;
- email и in-app notifications;
- поиск пользователей;
- глобальный каталог и поиск пользователей вне контекста Team;
- organizer/manager mutation;
- frontend flow.

Expand Down
11 changes: 9 additions & 2 deletions docs/team-permissions-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ Application, Team или Submission.
- `POST /applications/<application_id>/team/transfer-captain/` — передать роль,
body: `{"member_id": <id>}`.

Капитан и staff также могут использовать scoped-поиск кандидатов
`GET /applications/<application_id>/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.
Expand Down Expand Up @@ -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 не
затрагиваются.
10 changes: 9 additions & 1 deletion partner_programs/application_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +20,11 @@
app_name = "applications"

urlpatterns = [
path(
"<int:application_id>/team/invite-candidates/",
TeamInviteCandidateSearchView.as_view(),
name="team-invite-candidate-search",
),
path(
"<int:application_id>/team/invites/",
TeamInviteListCreateView.as_view(),
Expand Down
2 changes: 2 additions & 0 deletions partner_programs/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .submissions import SubmissionSerializer
from .team_invites import (
MyTeamInviteSerializer,
TeamInviteCandidateQuerySerializer,
TeamInviteCreateSerializer,
TeamInviteSerializer,
)
Expand Down Expand Up @@ -50,6 +51,7 @@
"ProgramProjectFilterRequestSerializer",
"SubmissionSerializer",
"MyTeamInviteSerializer",
"TeamInviteCandidateQuerySerializer",
"TeamInviteCreateSerializer",
"TeamInviteSerializer",
"TeamMemberSerializer",
Expand Down
17 changes: 17 additions & 0 deletions partner_programs/serializers/team_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions partner_programs/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions partner_programs/services/team_invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -23,6 +25,8 @@

User = get_user_model()

TEAM_INVITE_CANDIDATE_LIMIT = 20


class TeamInvitePermissionError(ApplicationTeamServiceError):
code = "team_invite_permission_denied"
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading