From dd902c0d75abea2a4d7456bb1e247250ec41ad4a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 9 Sep 2026 19:07:26 +0300 Subject: [PATCH] Expose program-scoped legacy current project application in detail --- docs/modules/partner-programs.md | 37 +- partner_programs/selectors.py | 33 +- ...est_program_current_project_application.py | 333 ++++++++++++++++++ partner_programs/views.py | 8 + 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 partner_programs/tests/test_program_current_project_application.py diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index f185f47e..3d983e35 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -40,7 +40,7 @@ проектов, фильтрации проектов, публикации проектов и подготовки Excel-выгрузок. - `partner_programs/selectors.py` - выборки участников для аналитики и - напоминаний. + напоминаний, legacy-заявка лидера в конкретной программе. - `partner_programs/permissions.py` - проверки менеджера программы, админа и лидера проекта. - `partner_programs/tasks.py` - celery-задача публикации проектов после @@ -107,6 +107,41 @@ Связанные курсы программы возвращаются в поле `courses`; для каждого курса указывается `is_available`. +`GET /programs//` всегда возвращает top-level +`current_project_application`: `null` для anonymous, authenticated non-member +или участника без legacy-связи; для участника-лидера контракт такой: + +```json +{ + "current_project_application": { + "project_id": 123, + "program_link_id": 700, + "submitted": false + } +} +``` + +Это legacy Angular flow `Project` × `PartnerProgramProject`, **не** production +модель `Application`. Поэтому DEV-имя `current_application` (#731) в production +не используется. Наличие настоящей Application, её status/project или участие +через TeamMember не определяют это поле; Application API +`/programs//applications/` и `/programs//applications/my/` не меняются. + +Selector `get_current_project_application(program_id=..., user_id=...)` читает +только `PartnerProgramProject` с `partner_program_id` запрошенной программы и +`project__leader_id` текущего пользователя. `submitted` передаётся напрямую из +этой связи, без расчёта lifecycle. Collaborator/Invite не считаются владельцами; +`PartnerProgramUserProfile.project`, singular `Project.partner_program` и связи +других программ не используются. Если legacy-данные содержат несколько подходящих +проектов одного лидера, выбирается минимальный pk **после фильтрации по программе**. + +Member gate остаётся во view: для anonymous/non-member selector не вызывается. +Для member добавляется ровно один SQL с тремя выбранными колонками и `LIMIT 1`, +независимо от количества проектов пользователя. Нет сканирования списка проектов +или pagination. Existing serializers и поля `application_policy`, +`welcome_acknowledged_at`, `courses`, `materials`, manager/member flags и dates +сохраняются. Apply/submit, canonical fields и их submission metadata не меняются. + ### 3. Пользователь регистрируется в программе `POST /programs//register/` создает `PartnerProgramUserProfile` для текущего diff --git a/partner_programs/selectors.py b/partner_programs/selectors.py index 6a8b9c62..ecb5f126 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,36 @@ MIN_ACTIVITY_DATETIME = datetime(1970, 1, 1, tzinfo=dt_timezone.utc) +class CurrentProjectApplication(TypedDict): + project_id: int + program_link_id: int + submitted: bool + + +def get_current_project_application( + *, program_id: int, user_id: int +) -> CurrentProjectApplication | None: + """Legacy leader-owned link in this program, not a production Application.""" + link = ( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=user_id, + ) + .annotate(program_link_id=F("pk")) + .values("project_id", "program_link_id", "submitted") + .order_by("pk") + .first() + ) + if link is None: + return None + + return { + "project_id": link["project_id"], + "program_link_id": link["program_link_id"], + "submitted": link["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_current_project_application.py b/partner_programs/tests/test_program_current_project_application.py new file mode 100644 index 00000000..cf48db09 --- /dev/null +++ b/partner_programs/tests/test_program_current_project_application.py @@ -0,0 +1,333 @@ +"""Program-scoped legacy leader DTO, separate from the production Application domain.""" + +from unittest.mock import patch + +from django.core.cache import cache +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from courses.models import CourseAccessType +from invites.models import Invite +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramMaterial, + PartnerProgramProject, + Team, + TeamMember, +) +from partner_programs.selectors import get_current_project_application +from partner_programs.serializers import ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, +) +from partner_programs.tests.helpers import ( + create_course, + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.views import PartnerProgramDetail +from projects.models import Collaborator + + +class ProgramCurrentProjectApplicationTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user() + self.program = create_partner_program() + self.project = create_project(leader=self.user, draft=True) + self.client.force_authenticate(self.user) + + def tearDown(self): + cache.clear() + super().tearDown() + + def get_detail(self, program=None): + program = program or self.program + response = self.client.get(f"/programs/{program.pk}/") + self.assertEqual(response.status_code, 200) + self.assertIn("current_project_application", response.data) + self.assertNotIn("current_application", response.data) + return response + + def assert_current_project(self, response, link): + self.assertEqual( + response.json()["current_project_application"], + { + "project_id": link.project_id, + "program_link_id": link.pk, + "submitted": link.submitted, + }, + ) + + def test_anonymous_user_skips_lookup_and_receives_null(self): + create_program_member(self.program, user=self.user) + create_program_project(self.program, project=self.project) + self.client.force_authenticate(None) + with patch("partner_programs.views.get_current_project_application") as lookup: + response = self.get_detail() + lookup.assert_not_called() + self.assertFalse(response.data["is_user_member"]) + self.assertIsNone(response.data["current_project_application"]) + + def test_non_member_with_owned_link_and_membership_elsewhere_skips_lookup(self): + create_program_project(self.program, project=self.project) + create_program_member(create_partner_program(), user=self.user) + with patch("partner_programs.views.get_current_project_application") as lookup: + response = self.get_detail() + lookup.assert_not_called() + self.assertFalse(response.data["is_user_member"]) + self.assertIsNone(response.data["current_project_application"]) + + def test_manager_without_membership_receives_null(self): + self.program.managers.add(self.user) + create_program_project(self.program, project=self.project) + with patch("partner_programs.views.get_current_project_application") as lookup: + response = self.get_detail() + lookup.assert_not_called() + self.assertTrue(response.data["is_user_manager"]) + self.assertIsNone(response.data["current_project_application"]) + + def test_member_without_legacy_link_receives_null(self): + create_program_member(self.program, user=self.user) + response = self.get_detail() + self.assertTrue(response.data["is_user_member"]) + self.assertIsNone(response.data["current_project_application"]) + + def test_member_receives_draft_link_without_production_application(self): + create_program_member(self.program, user=self.user) + link = create_program_project(self.program, project=self.project) + self.assertFalse(Application.objects.exists()) + self.assert_current_project(self.get_detail(), link) + + def test_submitted_is_raw_link_state_even_for_draft_noncompetitive_project(self): + create_program_member(self.program, user=self.user) + link = create_program_project(self.program, project=self.project, submitted=True) + self.assertFalse(self.program.is_competitive) + self.assertTrue(self.project.draft) + self.assert_current_project(self.get_detail(), link) + + def test_shared_project_returns_requested_program_link_not_first_link(self): + create_program_project(self.program, project=self.project, submitted=True) + program_b = create_partner_program() + create_program_member(program_b, user=self.user) + link_b = create_program_project(program_b, project=self.project) + self.assert_current_project(self.get_detail(program_b), link_b) + + def test_separate_projects_return_only_requested_program_application(self): + create_program_project(self.program, project=self.project) + program_b = create_partner_program() + create_program_member(program_b, user=self.user) + link_b = create_program_project( + program_b, project=create_project(leader=self.user) + ) + self.assert_current_project(self.get_detail(program_b), link_b) + + def test_other_leaders_project_is_not_current_project_application(self): + create_program_member(self.program, user=self.user) + create_program_project(self.program) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_collaborator_in_another_leaders_project_receives_null(self): + create_program_member(self.program, user=self.user) + link = create_program_project(self.program) + Collaborator.objects.create(user=self.user, project=link.project) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_invited_user_in_another_leaders_project_receives_null(self): + create_program_member(self.program, user=self.user) + link = create_program_project(self.program) + Invite.objects.create(user=self.user, project=link.project) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_team_membership_does_not_grant_legacy_application_ownership(self): + self.program.participation_format = ( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ) + self.program.team_min_size = 2 + self.program.team_max_size = 5 + self.program.save() + create_program_member(self.program, user=self.user) + other_leader = create_user() + create_program_member(self.program, user=other_leader) + foreign_project = create_project(leader=other_leader) + create_program_project(self.program, project=foreign_project) + application = Application.objects.create( + program=self.program, + user=other_leader, + created_by=other_leader, + project=foreign_project, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + team = Team.objects.create(application=application, captain=other_leader) + TeamMember.objects.create( + team=team, + user=other_leader, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, user=self.user, status=TeamMember.STATUS_ACCEPTED + ) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_duplicate_fallback_is_minimum_link_pk_not_minimum_project_pk(self): + create_program_member(self.program, user=self.user) + later_project = create_project(leader=self.user) + first_link = create_program_project( + self.program, project=later_project, submitted=True + ) + create_program_project(self.program, project=self.project) + self.assertLess(self.project.pk, later_project.pk) + self.assert_current_project(self.get_detail(), first_link) + + def test_profile_project_without_legacy_link_is_not_an_application(self): + create_program_member(self.program, user=self.user, project=self.project) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_stale_profile_project_does_not_override_authoritative_link(self): + create_program_member( + self.program, user=self.user, project=create_project(leader=self.user) + ) + link = create_program_project(self.program, project=self.project) + self.assert_current_project(self.get_detail(), link) + + def test_real_application_only_does_not_create_legacy_dto(self): + create_program_member(self.program, user=self.user) + Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + project=self.project, + ) + self.assertFalse(PartnerProgramProject.objects.exists()) + self.assertIsNone(self.get_detail().data["current_project_application"]) + + def test_real_application_and_legacy_link_return_only_legacy_data(self): + create_program_member(self.program, user=self.user) + application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + project=create_project(leader=self.user), + status=Application.STATUS_DRAFT, + ) + link = create_program_project(self.program, project=self.project, submitted=True) + applications_before = list(Application.objects.values()) + self.assertNotEqual(application.project_id, link.project_id) + self.assert_current_project(self.get_detail(), link) + self.assertEqual(list(Application.objects.values()), applications_before) + + def test_application_in_another_program_does_not_affect_result(self): + create_program_member(self.program, user=self.user) + other_program = create_partner_program() + create_program_member(other_program, user=self.user) + Application.objects.create( + program=other_program, + user=self.user, + created_by=self.user, + project=self.project, + ) + self.assertIsNone(self.get_detail().data["current_project_application"]) + link = create_program_project(self.program, project=self.project) + self.assert_current_project(self.get_detail(), link) + + def test_existing_production_detail_contract_is_preserved(self): + profile = create_program_member(self.program, user=self.user) + profile.welcome_acknowledged_at = timezone.now() + profile.save(update_fields=["welcome_acknowledged_at"]) + self.program.managers.add(self.user) + create_program_project(self.program, project=self.project) + course = create_course(self.program, access_type=CourseAccessType.PROGRAM_MEMBERS) + material = PartnerProgramMaterial.objects.create( + program=self.program, title="Guide", url="https://example.com/guide.pdf" + ) + for user, is_member in ((None, False), (create_user(), False), (self.user, True)): + with self.subTest(member=is_member, user=getattr(user, "pk", None)): + self.client.force_authenticate(user) + response = self.get_detail() + request = response.renderer_context["request"] + serializer_class = ( + PartnerProgramForMemberSerializer + if is_member + else PartnerProgramForUnregisteredUserSerializer + ) + expected = serializer_class( + PartnerProgramDetail.queryset.get(pk=self.program.pk), + context={ + "request": request, + "user": request.user, + "program_user_profile": profile if is_member else None, + }, + ).data + self.assertEqual( + { + key: value + for key, value in response.data.items() + if key != "current_project_application" + }, + {**expected, "is_user_member": is_member}, + ) + self.assertIn("application_policy", response.data) + self.assertIs(response.data["is_user_manager"], is_member) + self.assertEqual( + response.data["materials"], + [{"title": material.title, "url": material.url}], + ) + self.assertEqual( + response.data["courses"], + [{"id": course.pk, "title": course.title, "is_available": is_member}], + ) + if is_member: + self.assertIsNotNone(response.data["welcome_acknowledged_at"]) + profile.refresh_from_db() + self.assertIsNotNone(profile.welcome_acknowledged_at) + + def test_member_adds_exactly_one_lookup_to_existing_detail_queries(self): + create_program_member(self.program, user=self.user) + link = create_program_project(self.program, project=self.project) + self.get_detail() # Warm existing counters/content types and add_view once. + with patch( + "partner_programs.views.get_current_project_application", return_value=None + ), CaptureQueriesContext(connection) as without_lookup: + self.get_detail() + with patch( + "partner_programs.views.get_current_project_application", + wraps=get_current_project_application, + ) as lookup, CaptureQueriesContext(connection) as with_lookup: + response = self.get_detail() + lookup.assert_called_once_with(program_id=self.program.pk, user_id=self.user.pk) + self.assertEqual(len(with_lookup), len(without_lookup) + 1) + self.assert_current_project(response, link) + + def test_selector_is_one_query_for_empty_one_or_many_matching_projects(self): + with self.assertNumQueries(1): + result = get_current_project_application( + program_id=self.program.pk, user_id=self.user.pk + ) + self.assertIsNone(result) + link = create_program_project(self.program, project=self.project) + for extra_projects in (0, 19): + for _ in range(extra_projects): + create_program_project( + self.program, project=create_project(leader=self.user) + ) + with self.subTest(extra_projects=extra_projects), self.assertNumQueries(1): + result = get_current_project_application( + program_id=self.program.pk, user_id=self.user.pk + ) + self.assertEqual( + result, + { + "project_id": self.project.pk, + "program_link_id": link.pk, + "submitted": False, + }, + ) diff --git a/partner_programs/views.py b/partner_programs/views.py index 015f00b5..51cf771b 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -24,6 +24,7 @@ IsAdminOrManagerOrExpertOfProgram, IsProjectLeader, ) +from partner_programs.selectors import get_current_project_application from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -114,6 +115,12 @@ def get(self, request, *args, **kwargs): .first() ) is_user_member = program_user_profile is not None + current_project_application = None + if is_user_member: + current_project_application = get_current_project_application( + program_id=program.pk, + user_id=request.user.pk, + ) serializer_class = ( PartnerProgramForMemberSerializer if is_user_member @@ -129,6 +136,7 @@ def get(self, request, *args, **kwargs): ) data = serializer.data data["is_user_member"] = is_user_member + data["current_project_application"] = current_project_application if request.user.is_authenticated: add_view(program, request.user) return Response(data, status=status.HTTP_200_OK)