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
34 changes: 32 additions & 2 deletions docs/program-participation-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,38 @@ Policy хранится в `PartnerProgram` и задает допустимый
заявок.

Policy валидируется на уровне модели и базы данных и применяется
Application/Team service при create, смене формата и submit. Публичные Program
serializers при этом намеренно не расширены.
Application/Team service при create, смене формата и submit.

## Публичный detail contract

`GET /programs/<program_id>/` возвращает read-only объект
`application_policy`:

```json
{
"application_policy": {
"participation_format": "individual_or_team",
"allowed_participation_modes": ["individual", "team"],
"team_min_size": 2,
"team_max_size": 5,
"datetime_application_ends": "2026-08-31T20:59:59Z",
"is_application_deadline_passed": false
}
}
```

`allowed_participation_modes` содержит только финальные режимы, разрешенные
методом `allows_participation_mode`: `individual`, `team` или оба значения.
Черновой режим `undecided` клиенту для выбора формата не возвращается.

`team_min_size` и `team_max_size` равны `null` для `individual_only` и содержат
настроенные границы для форматов с командами. `datetime_application_ends`
сериализуется стандартным DRF DateTimeField; `null` означает отсутствие
отдельного дедлайна. `is_application_deadline_passed` вычисляется существующим
model method без fallback на legacy-дедлайны.

Policy добавлена только в detail serializers. Контракт списка `GET /programs/`
не изменен. Объект не содержит user-specific состояний регистрации или заявки.

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

Expand Down
2 changes: 2 additions & 0 deletions partner_programs/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .applications import ApplicationSerializer
from .fields import PartnerProgramFieldValueUpdateSerializer
from .programs import (
PartnerProgramApplicationPolicySerializer,
PartnerProgramBaseSerializerMixin,
PartnerProgramDataSchemaSerializer,
PartnerProgramFieldSerializer,
Expand Down Expand Up @@ -35,6 +36,7 @@
__all__ = [
"ApplicationSerializer",
"ApplicationTeamSummarySerializer",
"PartnerProgramApplicationPolicySerializer",
"PartnerProgramBaseSerializerMixin",
"PartnerProgramDataSchemaSerializer",
"PartnerProgramFieldSerializer",
Expand Down
42 changes: 42 additions & 0 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from courses.models import CourseContentStatus
from courses.services.access import resolve_course_availability
from partner_programs.models import (
Application,
PartnerProgram,
PartnerProgramField,
PartnerProgramFieldValue,
Expand All @@ -18,6 +19,41 @@
User = get_user_model()


class PartnerProgramApplicationPolicySerializer(serializers.Serializer):
"""Публичный read-only контракт правил создания Application."""

participation_format = serializers.ChoiceField(
choices=PartnerProgram.PARTICIPATION_FORMAT_CHOICES,
read_only=True,
)
allowed_participation_modes = serializers.SerializerMethodField()
team_min_size = serializers.IntegerField(read_only=True, allow_null=True)
team_max_size = serializers.IntegerField(read_only=True, allow_null=True)
datetime_application_ends = serializers.DateTimeField(
read_only=True,
allow_null=True,
)
is_application_deadline_passed = serializers.SerializerMethodField()

def get_allowed_participation_modes(
self,
program: PartnerProgram,
) -> list[str]:
# Клиенту нужны только финальные режимы; undecided остается состоянием
# черновика и намеренно не участвует в выборе формата заявки.
final_modes = (
Application.PARTICIPATION_MODE_INDIVIDUAL,
Application.PARTICIPATION_MODE_TEAM,
)
return [mode for mode in final_modes if program.allows_participation_mode(mode)]

def get_is_application_deadline_passed(
self,
program: PartnerProgram,
) -> bool:
return program.is_application_deadline_passed()


class PartnerProgramListSerializer(serializers.ModelSerializer):
"""Serializer for PartnerProgram model for list view."""

Expand Down Expand Up @@ -94,6 +130,10 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer):
materials = serializers.SerializerMethodField()
is_user_manager = serializers.SerializerMethodField()
courses = serializers.SerializerMethodField()
application_policy = PartnerProgramApplicationPolicySerializer(
source="*",
read_only=True,
)

def get_materials(self, program: PartnerProgram):
materials = program.materials.all()
Expand Down Expand Up @@ -180,6 +220,7 @@ class Meta:
"publish_projects_after_finish",
"is_user_manager",
"courses",
"application_policy",
)


Expand All @@ -205,6 +246,7 @@ class Meta:
"publish_projects_after_finish",
"is_user_manager",
"courses",
"application_policy",
)


Expand Down
123 changes: 123 additions & 0 deletions partner_programs/tests/test_program_application_policy_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from django.test import TestCase
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework.test import APIClient

from partner_programs.models import Application, PartnerProgram
from partner_programs.tests.helpers import (
create_partner_program,
create_program_member,
create_user,
)


class PartnerProgramApplicationPolicyAPITests(TestCase):
def setUp(self):
self.client = APIClient()

def get_policy(self, program: PartnerProgram) -> dict:
response = self.client.get(f"/programs/{program.pk}/")

self.assertEqual(response.status_code, 200)
self.assertIn("application_policy", response.data)
return response.data["application_policy"]

def test_individual_only_detail_exposes_individual_policy(self):
program = create_partner_program(
participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY),
team_min_size=None,
team_max_size=None,
datetime_application_ends=None,
)

policy = self.get_policy(program)

self.assertEqual(
policy,
{
"participation_format": "individual_only",
"allowed_participation_modes": [
Application.PARTICIPATION_MODE_INDIVIDUAL
],
"team_min_size": None,
"team_max_size": None,
"datetime_application_ends": None,
"is_application_deadline_passed": False,
},
)
self.assertNotIn(
Application.PARTICIPATION_MODE_UNDECIDED,
policy["allowed_participation_modes"],
)

def test_team_only_detail_exposes_sizes_and_future_deadline(self):
deadline = timezone.now() + timezone.timedelta(days=10)
program = create_partner_program(
participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY,
team_min_size=2,
team_max_size=5,
datetime_application_ends=deadline,
)

policy = self.get_policy(program)

self.assertEqual(policy["participation_format"], "team_only")
self.assertEqual(
policy["allowed_participation_modes"],
[Application.PARTICIPATION_MODE_TEAM],
)
self.assertEqual(policy["team_min_size"], 2)
self.assertEqual(policy["team_max_size"], 5)
self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline)
self.assertFalse(policy["is_application_deadline_passed"])
self.assertNotIn(
Application.PARTICIPATION_MODE_UNDECIDED,
policy["allowed_participation_modes"],
)

def test_individual_or_team_detail_marks_passed_deadline(self):
deadline = timezone.now() - timezone.timedelta(seconds=1)
program = create_partner_program(
participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM),
team_min_size=2,
team_max_size=4,
datetime_application_ends=deadline,
)

policy = self.get_policy(program)

self.assertEqual(policy["participation_format"], "individual_or_team")
self.assertEqual(
policy["allowed_participation_modes"],
[
Application.PARTICIPATION_MODE_INDIVIDUAL,
Application.PARTICIPATION_MODE_TEAM,
],
)
self.assertEqual(policy["team_min_size"], 2)
self.assertEqual(policy["team_max_size"], 4)
self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline)
self.assertTrue(policy["is_application_deadline_passed"])
self.assertNotIn(
Application.PARTICIPATION_MODE_UNDECIDED,
policy["allowed_participation_modes"],
)

def test_member_detail_uses_the_same_public_policy_contract(self):
program = create_partner_program()
member = create_user(prefix="policy-program-member")
create_program_member(program, user=member)
self.client.force_authenticate(member)

policy = self.get_policy(program)

self.assertEqual(policy["participation_format"], "individual_only")
self.assertEqual(policy["allowed_participation_modes"], ["individual"])

def test_program_list_contract_does_not_include_application_policy(self):
create_partner_program()

response = self.client.get("/programs/")

self.assertEqual(response.status_code, 200)
self.assertNotIn("application_policy", response.data["results"][0])
Loading