From dd6e2681065a87cb0ef04b2939edba0f3dbf98a3 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 8 Sep 2026 15:37:50 +0300 Subject: [PATCH] Expose current program application in detail --- docs/modules/partner-programs.md | 22 +++ partner_programs/selectors.py | 34 +++- partner_programs/tests/test_program_detail.py | 175 ++++++++++++++++++ partner_programs/views.py | 8 + 4 files changed, 238 insertions(+), 1 deletion(-) diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index f185f47e..35af76ba 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -107,6 +107,28 @@ Связанные курсы программы возвращаются в поле `courses`; для каждого курса указывается `is_available`. +Поле `current_application` присутствует в ответе всегда. Для анонимного +пользователя, не-участника и участника без заявки оно равно `null`. Для +участника с заявкой контракт выглядит так: + +```json +{ + "current_application": { + "project_id": 123, + "program_link_id": 700, + "submitted": false + } +} +``` + +Заявка определяется строго для пары «текущий пользователь + текущая программа» +через `PartnerProgramProject`, причем владельцем считается только лидер +`Project`. Поле `submitted` передается напрямую из +`PartnerProgramProject.submitted`. Выборка не зависит от legacy-поля +`partner_program` в serializers проекта и не использует участие пользователя +в чужом проекте как collaborator. Если legacy-данные содержат несколько заявок +одного лидера в программе, детерминированно выбирается связь с минимальным `pk`. + ### 3. Пользователь регистрируется в программе `POST /programs//register/` создает `PartnerProgramUserProfile` для текущего diff --git a/partner_programs/selectors.py b/partner_programs/selectors.py index 6a8b9c62..5829a8bd 100644 --- a/partner_programs/selectors.py +++ b/partner_programs/selectors.py @@ -1,7 +1,8 @@ from datetime import datetime, timezone as dt_timezone +from typing import TypedDict from django.contrib.auth import get_user_model -from django.db.models import DateTimeField, Exists, OuterRef, Q, Value +from django.db.models import DateTimeField, Exists, F, OuterRef, Q, Value from django.db.models.functions import Coalesce, Greatest from partner_programs.models import ( @@ -15,6 +16,37 @@ MIN_ACTIVITY_DATETIME = datetime(1970, 1, 1, tzinfo=dt_timezone.utc) +class CurrentProgramApplication(TypedDict): + project_id: int + program_link_id: int + submitted: bool + + +def get_current_program_application( + *, program_id: int, user_id: int +) -> CurrentProgramApplication | None: + """Возвращает первую по pk заявку лидера строго в указанной программе.""" + + application = ( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=user_id, + ) + .annotate(program_link_id=F("pk")) + .order_by("program_link_id") + .values("project_id", "program_link_id", "submitted") + .first() + ) + if application is None: + return None + + return { + "project_id": application["project_id"], + "program_link_id": application["program_link_id"], + "submitted": application["submitted"], + } + + def programs_with_submission_deadline_on(target_date): return PartnerProgram.objects.filter( Q(datetime_project_submission_ends__date=target_date) diff --git a/partner_programs/tests/test_program_detail.py b/partner_programs/tests/test_program_detail.py index 855b1103..6a74965c 100644 --- a/partner_programs/tests/test_program_detail.py +++ b/partner_programs/tests/test_program_detail.py @@ -2,10 +2,13 @@ from rest_framework.test import APIClient from courses.models import CourseAccessType, CourseContentStatus +from projects.models import Collaborator from partner_programs.tests.helpers import ( create_course, create_partner_program, create_program_member, + create_program_project, + create_project, create_user, ) @@ -100,3 +103,175 @@ def test_detail_marks_program_only_courses_as_unavailable_for_non_member(self): }, ], ) + + +class PartnerProgramCurrentApplicationDetailTests(TestCase): + def setUp(self): + self.client = APIClient() + + def get_detail(self, program): + return self.client.get(f"/programs/{program.id}/") + + def test_anonymous_user_has_no_current_application(self): + program = create_partner_program() + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["current_application"]) + + def test_authenticated_non_member_has_no_current_application(self): + program = create_partner_program() + user = create_user(prefix="non-member-application") + project = create_project(leader=user) + create_program_project(program, project=project) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["is_user_member"]) + self.assertIsNone(response.data["current_application"]) + + def test_member_without_application_has_no_current_application(self): + program = create_partner_program() + user = create_user(prefix="member-without-application") + create_program_member(program, user=user) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["is_user_member"]) + self.assertIsNone(response.data["current_application"]) + + def test_member_receives_draft_current_application(self): + program = create_partner_program() + user = create_user(prefix="draft-application") + create_program_member(program, user=user) + project = create_project(leader=user, draft=True) + link = create_program_project(program, project=project) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["current_application"], + { + "project_id": project.id, + "program_link_id": link.id, + "submitted": False, + }, + ) + + def test_member_receives_submitted_state_from_program_link(self): + program = create_partner_program() + user = create_user(prefix="submitted-application") + create_program_member(program, user=user) + project = create_project(leader=user) + link = create_program_project(program, project=project, submitted=True) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["current_application"], + { + "project_id": project.id, + "program_link_id": link.id, + "submitted": True, + }, + ) + + def test_multi_program_project_returns_link_for_requested_program(self): + program_a = create_partner_program(name="Program A") + program_b = create_partner_program(name="Program B") + user = create_user(prefix="multi-program-application") + create_program_member(program_b, user=user) + project = create_project(leader=user) + create_program_project(program_a, project=project) + link_b = create_program_project(program_b, project=project) + self.client.force_authenticate(user) + + response = self.get_detail(program_b) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["current_application"]["project_id"], project.id) + self.assertEqual( + response.data["current_application"]["program_link_id"], link_b.id + ) + + def test_projects_from_other_programs_do_not_affect_application(self): + program_a = create_partner_program(name="Independent Program A") + program_b = create_partner_program(name="Independent Program B") + user = create_user(prefix="independent-program-application") + create_program_member(program_b, user=user) + project_a = create_project(leader=user, name="Project A") + project_b = create_project(leader=user, name="Project B") + create_program_project(program_a, project=project_a) + link_b = create_program_project(program_b, project=project_b) + self.client.force_authenticate(user) + + response = self.get_detail(program_b) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["current_application"], + { + "project_id": project_b.id, + "program_link_id": link_b.id, + "submitted": False, + }, + ) + + def test_other_users_project_is_not_current_application(self): + program = create_partner_program() + user = create_user(prefix="application-viewer") + other_user = create_user(prefix="other-application-leader") + create_program_member(program, user=user) + create_program_project(program, project=create_project(leader=other_user)) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["current_application"]) + + def test_collaborator_project_is_not_current_application(self): + program = create_partner_program() + user = create_user(prefix="application-collaborator") + leader = create_user(prefix="collaborator-project-leader") + create_program_member(program, user=user) + project = create_project(leader=leader) + create_program_project(program, project=project) + Collaborator.objects.create(user=user, project=project, role="Participant") + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["current_application"]) + + def test_legacy_duplicate_uses_lowest_program_link_id(self): + program = create_partner_program() + user = create_user(prefix="duplicate-application") + create_program_member(program, user=user) + first_project = create_project(leader=user, name="First application") + second_project = create_project(leader=user, name="Second application") + first_link = create_program_project(program, project=first_project) + create_program_project(program, project=second_project) + self.client.force_authenticate(user) + + response = self.get_detail(program) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["current_application"], + { + "project_id": first_project.id, + "program_link_id": first_link.id, + "submitted": False, + }, + ) diff --git a/partner_programs/views.py b/partner_programs/views.py index f7e594c2..f92b6159 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -29,6 +29,7 @@ IsProjectLeader, can_manage_program, ) +from partner_programs.selectors import get_current_program_application from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -144,6 +145,12 @@ def get(self, request, *args, **kwargs): .first() ) is_user_member = program_user_profile is not None + current_application = None + if is_user_member: + current_application = get_current_program_application( + program_id=program.pk, + user_id=request.user.pk, + ) serializer_class = ( PartnerProgramForMemberSerializer if is_user_member @@ -159,6 +166,7 @@ def get(self, request, *args, **kwargs): ) data = serializer.data data["is_user_member"] = is_user_member + data["current_application"] = current_application if request.user.is_authenticated: add_view(program, request.user) return Response(data, status=status.HTTP_200_OK)