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
22 changes: 22 additions & 0 deletions docs/modules/partner-programs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/register/` создает `PartnerProgramUserProfile` для текущего
Expand Down
34 changes: 33 additions & 1 deletion partner_programs/selectors.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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)
Expand Down
175 changes: 175 additions & 0 deletions partner_programs/tests/test_program_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
},
)
8 changes: 8 additions & 0 deletions partner_programs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
IsProjectLeader,
can_manage_program,
)
from partner_programs.selectors import get_current_program_application
from partner_programs.serializers import (
PartnerProgramDataSchemaSerializer,
PartnerProgramFieldSerializer,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading