Skip to content

Commit cf88061

Browse files
authored
Merge pull request #661 from PROCOLLAB-github/feature/application-team-service
Add application team domain service
2 parents b573898 + 19521a3 commit cf88061

10 files changed

Lines changed: 1651 additions & 110 deletions

docs/application-team-model.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
- `Team` — команда, собранная только для этой заявки;
99
- `TeamMember` — членство пользователя в команде заявки.
1010

11-
Публичного Team API и атомарного service создания команды пока нет. Модели
12-
подготавливают структуру данных для следующих PR, не меняя текущий
13-
индивидуальный API flow.
11+
Публичного Team API пока нет. Транзакционный Application/Team service создает
12+
командный draft вместе с Team и accepted-капитаном и проверяет полный invariant
13+
перед submit. Состав команды через публичный API пока не редактируется.
1414

1515
## Participation mode
1616

@@ -110,28 +110,26 @@ TeamMember, а TeamMember уже требует сохраненную Team.
110110

111111
## Текущий технический порядок создания
112112

113-
До появления публичного API техническая последовательность выглядит так:
113+
Domain service выполняет последовательность атомарно:
114114

115115
1. создать или обновить Application с `participation_mode=team`;
116116
2. создать Team с captain, совпадающим с `Application.user`;
117117
3. создать accepted TeamMember с `role=captain` и тем же пользователем.
118118

119-
Клиентам нельзя использовать эту последовательность напрямую. Сейчас нет
120-
публичных serializers/views/URLs и транзакционного service, который откатит
121-
частично созданную команду.
119+
Клиенты используют существующий Application create endpoint с
120+
`participation_mode=team` и необязательным `team_name`. При ошибке service
121+
откатывает все три шага. Публичных Team serializers/views/URLs и управления
122+
участниками по-прежнему нет.
122123

123124
## Вне текущего MVP
124125

125126
Следующие PR должны добавить:
126127

127-
- атомарный creation/invariant service;
128-
- проверку Registration и одной активной заявки на Program;
129128
- Team permissions и публичный Team API;
130129
- блокировку состава по Application status;
131130
- TeamInvite, accept/decline/revoke/expire;
132131
- email и внутренние уведомления;
133-
- program policy по формату и размеру команды;
134132
- frontend wizard и вкладку команды.
135133

136-
Project, `projects.Collaborator`, legacy `invites.Invite`, Submission и
137-
существующие Application endpoints этим слоем данных не изменяются.
134+
Legacy Application withdraw, Project/`projects.Collaborator`,
135+
`invites.Invite` и Submission flow этим service не изменяются.

docs/application-team-service.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Application Team Service
2+
3+
## Назначение
4+
5+
`partner_programs.services.application_team` — транзакционный domain service
6+
для индивидуальных и командных `Application`. Он отделяет бизнес-правила от
7+
DRF views и не зависит от HTTP response.
8+
9+
Service предоставляет четыре публичные операции:
10+
11+
- `create_or_get_application()`;
12+
- `change_application_participation_mode()`;
13+
- `validate_team_invariants()`;
14+
- `submit_application()`.
15+
16+
## Доменные ошибки
17+
18+
Все ожидаемые отказы наследуются от `ApplicationTeamServiceError` и содержат
19+
стабильные `code`, `detail` и `field`. Views преобразуют их в DRF
20+
`ValidationError`, но сам service не импортирует DRF.
21+
22+
Основные коды: `registration_required`, `application_deadline_passed`,
23+
`participation_mode_not_allowed`, `participation_mode_undecided`,
24+
`active_application_conflict`, `team_required`, `team_not_allowed`,
25+
`team_has_other_members`, `captain_member_missing`, `captain_mismatch`,
26+
`team_size_invalid`, `team_member_registration_missing` и
27+
`application_not_editable`.
28+
29+
## Создание Application
30+
31+
`create_or_get_application()` выполняет в одной транзакции:
32+
33+
1. блокирует строку `PartnerProgram`;
34+
2. проверяет `PartnerProgramUserProfile` владельца;
35+
3. проверяет `datetime_application_ends` без fallback на legacy-дедлайны;
36+
4. валидирует формат по Program policy;
37+
5. ищет активную собственную Application и accepted-членство в другой Team;
38+
6. создает individual/undecided draft либо team draft;
39+
7. для team атомарно создает `Team` и accepted captain `TeamMember`.
40+
41+
Повторный совместимый запрос возвращает существующую Application с
42+
`created=False`. Отличающийся формат, form data, Project или Team name не
43+
перезаписывает существующий draft скрытым образом и дает конфликт.
44+
45+
`undecided` допустим для draft и не создает Team. Старый API-запрос без
46+
`participation_mode` преобразуется view в `individual`, поэтому существующий
47+
frontend сохраняет прежний формат.
48+
49+
## Смена формата
50+
51+
`change_application_participation_mode()` доступен только владельцу draft до
52+
application deadline:
53+
54+
- `undecided → individual` меняет только поле;
55+
- `undecided/individual → team` создает Team и капитана;
56+
- `individual → undecided` разрешен, пока Team отсутствует;
57+
- `team → individual` удаляет Team только при наличии единственной записи
58+
accepted-капитана;
59+
- `team → undecided` запрещен, чтобы не удалять состав неявно;
60+
- повторный `team → team` может изменить `team_name`.
61+
62+
PATCH Application вызывает эту операцию до сохранения остальных полей в общей
63+
транзакции. Serializer намеренно не записывает `participation_mode` и
64+
`team_name` напрямую.
65+
66+
## Конфликт активного участия
67+
68+
Активными остаются `draft`, `submitted` и `approved`. Для пользователя service
69+
учитывает обе роли:
70+
71+
- `Application.user`;
72+
- accepted `TeamMember` связанной активной Application той же Program.
73+
74+
Текущая Application исключается при change/submit. Терминальные Application и
75+
членство в другой Program не блокируют новую заявку.
76+
77+
Строка Program блокируется через `select_for_update()` как общая точка
78+
сериализации, после чего блокируются найденные Application/TeamMember. Это
79+
закрывает гонки между операциями, которые проходят через service. Существующая
80+
partial unique constraint дополнительно защищает две собственные активные
81+
Application.
82+
83+
Cross-table invariant нельзя выразить обычным UniqueConstraint. Прямые записи
84+
через admin/model и будущий Team API должны использовать тот же service или
85+
отдельную транзакционную операцию принятия участника.
86+
87+
## Полный Team invariant
88+
89+
`validate_team_invariants()` ничего не изменяет и проверяет:
90+
91+
- Team существует только для `participation_mode=team`;
92+
- `Team.application` и капитан согласованы с Application;
93+
- есть ровно один accepted captain member;
94+
- все accepted-участники имеют Registration этой Program;
95+
- accepted-состав находится между `team_min_size` и `team_max_size`.
96+
97+
Капитан входит в размер команды. `invited`, `declined`, `removed` и `left`
98+
сохраняют историю, но не считаются участниками при submit.
99+
100+
## Submit
101+
102+
`submit_application()` сохраняет текущий owner/staff access contract. Staff не
103+
обходит Registration, Program policy, deadline или Team invariant.
104+
105+
Для draft операция проверяет Registration владельца, application deadline,
106+
финальный формат, cross-table конфликты и Team invariant, затем атомарно
107+
устанавливает `submitted` и `submitted_at`.
108+
109+
Повторный submit уже отправленной Application идемпотентен: он возвращает
110+
текущую запись, не меняет `submitted_at` и не падает из-за дедлайна, который
111+
истек после первой отправки.
112+
113+
## API contract
114+
115+
Существующие routes и throttle scope не изменены:
116+
117+
- `POST /programs/<id>/applications/` принимает необязательные
118+
`participation_mode` и write-only `team_name`;
119+
- отсутствие mode означает `individual`;
120+
- response дополнен `participation_mode`, но не раскрывает TeamMember;
121+
- `PATCH /applications/<id>/` проводит mode/team name через service;
122+
- `POST /applications/<id>/submit/` вызывает транзакционный submit service.
123+
124+
Публичного Team CRUD, управления участниками и TeamInvite в этом изменении нет.
125+
126+
## Ограничения concurrency и MVP
127+
128+
Production-база с row-level locking сериализует service-операции одной Program.
129+
SQLite не реализует полноценный `select_for_update`, поэтому автоматический
130+
тест проверяет устойчивую последовательную конфликтную операцию без threading.
131+
132+
Service пока не управляет приглашениями/принятием участников и не блокирует
133+
прямое редактирование моделей через admin. Нет Team permissions для обычных
134+
members, передачи капитанства, returned Application и organizer review.

0 commit comments

Comments
 (0)