Skip to content

Commit 08fdff6

Browse files
authored
Merge pull request #665 from PROCOLLAB-github/feature/program-application-policy-api
Expose program application policy
2 parents cdc81c8 + 44c5f37 commit 08fdff6

4 files changed

Lines changed: 199 additions & 2 deletions

File tree

docs/program-participation-policy.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,38 @@ Policy хранится в `PartnerProgram` и задает допустимый
88
заявок.
99

1010
Policy валидируется на уровне модели и базы данных и применяется
11-
Application/Team service при create, смене формата и submit. Публичные Program
12-
serializers при этом намеренно не расширены.
11+
Application/Team service при create, смене формата и submit.
12+
13+
## Публичный detail contract
14+
15+
`GET /programs/<program_id>/` возвращает read-only объект
16+
`application_policy`:
17+
18+
```json
19+
{
20+
"application_policy": {
21+
"participation_format": "individual_or_team",
22+
"allowed_participation_modes": ["individual", "team"],
23+
"team_min_size": 2,
24+
"team_max_size": 5,
25+
"datetime_application_ends": "2026-08-31T20:59:59Z",
26+
"is_application_deadline_passed": false
27+
}
28+
}
29+
```
30+
31+
`allowed_participation_modes` содержит только финальные режимы, разрешенные
32+
методом `allows_participation_mode`: `individual`, `team` или оба значения.
33+
Черновой режим `undecided` клиенту для выбора формата не возвращается.
34+
35+
`team_min_size` и `team_max_size` равны `null` для `individual_only` и содержат
36+
настроенные границы для форматов с командами. `datetime_application_ends`
37+
сериализуется стандартным DRF DateTimeField; `null` означает отсутствие
38+
отдельного дедлайна. `is_application_deadline_passed` вычисляется существующим
39+
model method без fallback на legacy-дедлайны.
40+
41+
Policy добавлена только в detail serializers. Контракт списка `GET /programs/`
42+
не изменен. Объект не содержит user-specific состояний регистрации или заявки.
1343

1444
## Формат участия
1545

partner_programs/serializers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .applications import ApplicationSerializer
22
from .fields import PartnerProgramFieldValueUpdateSerializer
33
from .programs import (
4+
PartnerProgramApplicationPolicySerializer,
45
PartnerProgramBaseSerializerMixin,
56
PartnerProgramDataSchemaSerializer,
67
PartnerProgramFieldSerializer,
@@ -35,6 +36,7 @@
3536
__all__ = [
3637
"ApplicationSerializer",
3738
"ApplicationTeamSummarySerializer",
39+
"PartnerProgramApplicationPolicySerializer",
3840
"PartnerProgramBaseSerializerMixin",
3941
"PartnerProgramDataSchemaSerializer",
4042
"PartnerProgramFieldSerializer",

partner_programs/serializers/programs.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from courses.models import CourseContentStatus
66
from courses.services.access import resolve_course_availability
77
from partner_programs.models import (
8+
Application,
89
PartnerProgram,
910
PartnerProgramField,
1011
PartnerProgramFieldValue,
@@ -18,6 +19,41 @@
1819
User = get_user_model()
1920

2021

22+
class PartnerProgramApplicationPolicySerializer(serializers.Serializer):
23+
"""Публичный read-only контракт правил создания Application."""
24+
25+
participation_format = serializers.ChoiceField(
26+
choices=PartnerProgram.PARTICIPATION_FORMAT_CHOICES,
27+
read_only=True,
28+
)
29+
allowed_participation_modes = serializers.SerializerMethodField()
30+
team_min_size = serializers.IntegerField(read_only=True, allow_null=True)
31+
team_max_size = serializers.IntegerField(read_only=True, allow_null=True)
32+
datetime_application_ends = serializers.DateTimeField(
33+
read_only=True,
34+
allow_null=True,
35+
)
36+
is_application_deadline_passed = serializers.SerializerMethodField()
37+
38+
def get_allowed_participation_modes(
39+
self,
40+
program: PartnerProgram,
41+
) -> list[str]:
42+
# Клиенту нужны только финальные режимы; undecided остается состоянием
43+
# черновика и намеренно не участвует в выборе формата заявки.
44+
final_modes = (
45+
Application.PARTICIPATION_MODE_INDIVIDUAL,
46+
Application.PARTICIPATION_MODE_TEAM,
47+
)
48+
return [mode for mode in final_modes if program.allows_participation_mode(mode)]
49+
50+
def get_is_application_deadline_passed(
51+
self,
52+
program: PartnerProgram,
53+
) -> bool:
54+
return program.is_application_deadline_passed()
55+
56+
2157
class PartnerProgramListSerializer(serializers.ModelSerializer):
2258
"""Serializer for PartnerProgram model for list view."""
2359

@@ -94,6 +130,10 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer):
94130
materials = serializers.SerializerMethodField()
95131
is_user_manager = serializers.SerializerMethodField()
96132
courses = serializers.SerializerMethodField()
133+
application_policy = PartnerProgramApplicationPolicySerializer(
134+
source="*",
135+
read_only=True,
136+
)
97137

98138
def get_materials(self, program: PartnerProgram):
99139
materials = program.materials.all()
@@ -180,6 +220,7 @@ class Meta:
180220
"publish_projects_after_finish",
181221
"is_user_manager",
182222
"courses",
223+
"application_policy",
183224
)
184225

185226

@@ -205,6 +246,7 @@ class Meta:
205246
"publish_projects_after_finish",
206247
"is_user_manager",
207248
"courses",
249+
"application_policy",
208250
)
209251

210252

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from django.test import TestCase
2+
from django.utils import timezone
3+
from django.utils.dateparse import parse_datetime
4+
from rest_framework.test import APIClient
5+
6+
from partner_programs.models import Application, PartnerProgram
7+
from partner_programs.tests.helpers import (
8+
create_partner_program,
9+
create_program_member,
10+
create_user,
11+
)
12+
13+
14+
class PartnerProgramApplicationPolicyAPITests(TestCase):
15+
def setUp(self):
16+
self.client = APIClient()
17+
18+
def get_policy(self, program: PartnerProgram) -> dict:
19+
response = self.client.get(f"/programs/{program.pk}/")
20+
21+
self.assertEqual(response.status_code, 200)
22+
self.assertIn("application_policy", response.data)
23+
return response.data["application_policy"]
24+
25+
def test_individual_only_detail_exposes_individual_policy(self):
26+
program = create_partner_program(
27+
participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY),
28+
team_min_size=None,
29+
team_max_size=None,
30+
datetime_application_ends=None,
31+
)
32+
33+
policy = self.get_policy(program)
34+
35+
self.assertEqual(
36+
policy,
37+
{
38+
"participation_format": "individual_only",
39+
"allowed_participation_modes": [
40+
Application.PARTICIPATION_MODE_INDIVIDUAL
41+
],
42+
"team_min_size": None,
43+
"team_max_size": None,
44+
"datetime_application_ends": None,
45+
"is_application_deadline_passed": False,
46+
},
47+
)
48+
self.assertNotIn(
49+
Application.PARTICIPATION_MODE_UNDECIDED,
50+
policy["allowed_participation_modes"],
51+
)
52+
53+
def test_team_only_detail_exposes_sizes_and_future_deadline(self):
54+
deadline = timezone.now() + timezone.timedelta(days=10)
55+
program = create_partner_program(
56+
participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY,
57+
team_min_size=2,
58+
team_max_size=5,
59+
datetime_application_ends=deadline,
60+
)
61+
62+
policy = self.get_policy(program)
63+
64+
self.assertEqual(policy["participation_format"], "team_only")
65+
self.assertEqual(
66+
policy["allowed_participation_modes"],
67+
[Application.PARTICIPATION_MODE_TEAM],
68+
)
69+
self.assertEqual(policy["team_min_size"], 2)
70+
self.assertEqual(policy["team_max_size"], 5)
71+
self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline)
72+
self.assertFalse(policy["is_application_deadline_passed"])
73+
self.assertNotIn(
74+
Application.PARTICIPATION_MODE_UNDECIDED,
75+
policy["allowed_participation_modes"],
76+
)
77+
78+
def test_individual_or_team_detail_marks_passed_deadline(self):
79+
deadline = timezone.now() - timezone.timedelta(seconds=1)
80+
program = create_partner_program(
81+
participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM),
82+
team_min_size=2,
83+
team_max_size=4,
84+
datetime_application_ends=deadline,
85+
)
86+
87+
policy = self.get_policy(program)
88+
89+
self.assertEqual(policy["participation_format"], "individual_or_team")
90+
self.assertEqual(
91+
policy["allowed_participation_modes"],
92+
[
93+
Application.PARTICIPATION_MODE_INDIVIDUAL,
94+
Application.PARTICIPATION_MODE_TEAM,
95+
],
96+
)
97+
self.assertEqual(policy["team_min_size"], 2)
98+
self.assertEqual(policy["team_max_size"], 4)
99+
self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline)
100+
self.assertTrue(policy["is_application_deadline_passed"])
101+
self.assertNotIn(
102+
Application.PARTICIPATION_MODE_UNDECIDED,
103+
policy["allowed_participation_modes"],
104+
)
105+
106+
def test_member_detail_uses_the_same_public_policy_contract(self):
107+
program = create_partner_program()
108+
member = create_user(prefix="policy-program-member")
109+
create_program_member(program, user=member)
110+
self.client.force_authenticate(member)
111+
112+
policy = self.get_policy(program)
113+
114+
self.assertEqual(policy["participation_format"], "individual_only")
115+
self.assertEqual(policy["allowed_participation_modes"], ["individual"])
116+
117+
def test_program_list_contract_does_not_include_application_policy(self):
118+
create_partner_program()
119+
120+
response = self.client.get("/programs/")
121+
122+
self.assertEqual(response.status_code, 200)
123+
self.assertNotIn("application_policy", response.data["results"][0])

0 commit comments

Comments
 (0)