From 44c5f372fb47f089e52fb3ba7ed1a521694e5c6a Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 23 Jul 2026 01:54:04 +0300 Subject: [PATCH] Expose program application policy --- docs/program-participation-policy.md | 34 ++++- partner_programs/serializers/__init__.py | 2 + partner_programs/serializers/programs.py | 42 ++++++ .../test_program_application_policy_api.py | 123 ++++++++++++++++++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 partner_programs/tests/test_program_application_policy_api.py diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md index 9873e8e0..cd89d028 100644 --- a/docs/program-participation-policy.md +++ b/docs/program-participation-policy.md @@ -8,8 +8,38 @@ Policy хранится в `PartnerProgram` и задает допустимый заявок. Policy валидируется на уровне модели и базы данных и применяется -Application/Team service при create, смене формата и submit. Публичные Program -serializers при этом намеренно не расширены. +Application/Team service при create, смене формата и submit. + +## Публичный detail contract + +`GET /programs//` возвращает 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 состояний регистрации или заявки. ## Формат участия diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 060f6d3f..e08e8933 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -1,6 +1,7 @@ from .applications import ApplicationSerializer from .fields import PartnerProgramFieldValueUpdateSerializer from .programs import ( + PartnerProgramApplicationPolicySerializer, PartnerProgramBaseSerializerMixin, PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -35,6 +36,7 @@ __all__ = [ "ApplicationSerializer", "ApplicationTeamSummarySerializer", + "PartnerProgramApplicationPolicySerializer", "PartnerProgramBaseSerializerMixin", "PartnerProgramDataSchemaSerializer", "PartnerProgramFieldSerializer", diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 80f02375..7fc084e1 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -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, @@ -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.""" @@ -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() @@ -180,6 +220,7 @@ class Meta: "publish_projects_after_finish", "is_user_manager", "courses", + "application_policy", ) @@ -205,6 +246,7 @@ class Meta: "publish_projects_after_finish", "is_user_manager", "courses", + "application_policy", ) diff --git a/partner_programs/tests/test_program_application_policy_api.py b/partner_programs/tests/test_program_application_policy_api.py new file mode 100644 index 00000000..f7bd0aa8 --- /dev/null +++ b/partner_programs/tests/test_program_application_policy_api.py @@ -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])