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
36 changes: 34 additions & 2 deletions docs/manager-program-overview-api.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- Roadmap: DEV-076, DEV-056 -->
<!-- Roadmap: DEV-076, DEV-056, DEV-091 -->

# Manager program overview API

Expand All @@ -22,6 +22,7 @@ GET /programs/<program_id>/manager-overview/
{
"program": {"id": 1, "name": "Program"},
"registrations": {"total": 0},
"participants": {"total": 0},
"applications": {
"total": 0,
"by_status": {
Expand Down Expand Up @@ -65,7 +66,11 @@ GET /programs/<program_id>/manager-overview/

## Семантика счетчиков

- `registrations.total` — количество `PartnerProgramUserProfile` программы.
- `registrations.total` — количество всех записей `PartnerProgramUserProfile`
программы, включая исторические записи с `user=null`.
- `participants.total` — количество уникальных ненулевых `user_id` в
`PartnerProgramUserProfile`. Записи удаленного пользователя с `user=null` не
учитываются, один пользователь учитывается только один раз.
- `applications.total` и `by_status` — заявки программы целиком и по статусам.
- `applications.by_participation_mode` — заявки по режимам `undecided`,
`individual` и `team`.
Expand All @@ -83,6 +88,33 @@ GET /programs/<program_id>/manager-overview/
Подсчеты выполняются фиксированным набором ORM aggregate-запросов и не зависят
от количества участников.

## Программы, доступные организатору

```http
GET /programs/managed/
```

Endpoint требует авторизацию и возвращает компактный непагинированный список:

```json
[
{
"id": 123,
"name": "Кейс-чемпионат",
"draft": true
}
]
```

Обычный пользователь получает только программы, где он указан в
`PartnerProgram.managers`. Опубликованные и draft-программы включаются в список.
Staff и superuser получают все программы. Пользователь без управляемых программ
получает `200` и пустой массив, неавторизованный пользователь — `401`.

Сортировка стабильна: сначала по `name`, затем по `id`. Ответ строится одним
запросом и не загружает связанные сущности для каждой программы. Контракт
существующего публичного `GET /programs/` не изменяется.

## Ограничения данных

Ответ не содержит email, ФИО, `form_data`, `partner_program_data`, ссылки на
Expand Down
18 changes: 18 additions & 0 deletions partner_programs/manager_overview_views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Roadmap: DEV-076, DEV-056

from drf_yasg.utils import swagger_auto_schema
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from partner_programs.models import PartnerProgram
from partner_programs.permissions import IsAdminOrManagerOfProgram
from partner_programs.serializers.manager_overview import (
ManagedProgramSerializer,
ManagerProgramOverviewSerializer,
)
from partner_programs.services.manager_overview import (
Expand Down Expand Up @@ -35,3 +38,18 @@ def get(self, request, program_id):
serializer = ManagerProgramOverviewSerializer(data=overview)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)


class ManagedProgramListView(ListAPIView):
"""Возвращает программы, для которых пользователь имеет права организатора."""

permission_classes = [IsAuthenticated]
serializer_class = ManagedProgramSerializer
pagination_class = None

def get_queryset(self):
queryset = PartnerProgram.objects.only("id", "name", "draft")
user = self.request.user
if not (user.is_staff or user.is_superuser):
queryset = queryset.filter(managers=user)
return queryset.order_by("name", "id")
7 changes: 7 additions & 0 deletions partner_programs/serializers/manager_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,15 @@ class ProgramOverviewEvaluationsSerializer(ProgramOverviewTotalSerializer):
class ManagerProgramOverviewSerializer(serializers.Serializer):
program = ProgramOverviewProgramSerializer()
registrations = ProgramOverviewTotalSerializer()
participants = ProgramOverviewTotalSerializer()
applications = ProgramOverviewApplicationsSerializer()
teams = ProgramOverviewTeamsSerializer()
submissions = ProgramOverviewSubmissionsSerializer()
expert_assignments = ProgramOverviewAssignmentsSerializer()
evaluations = ProgramOverviewEvaluationsSerializer()


class ManagedProgramSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
draft = serializers.BooleanField()
16 changes: 13 additions & 3 deletions partner_programs/services/manager_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ def _count_by_choice(choices, field_name):

def build_manager_program_overview(program: PartnerProgram) -> dict:
"""Возвращает обезличенные агрегаты этапов участия в одной программе."""
registrations = PartnerProgramUserProfile.objects.filter(
registration_metrics = PartnerProgramUserProfile.objects.filter(
partner_program=program
).aggregate(total=Count("id"))
).aggregate(
total=Count("id"),
participants_total=Count(
"user_id",
filter=Q(user_id__isnull=False),
distinct=True,
),
)

application_metrics = Application.objects.filter(program=program).aggregate(
total=Count("id"),
Expand Down Expand Up @@ -84,7 +91,10 @@ def build_manager_program_overview(program: PartnerProgram) -> dict:
"id": program.pk,
"name": program.name,
},
"registrations": registrations,
"registrations": {"total": registration_metrics["total"]},
"participants": {
"total": registration_metrics["participants_total"],
},
"applications": {
"total": application_metrics["total"],
"by_status": _choice_counts(
Expand Down
125 changes: 125 additions & 0 deletions partner_programs/tests/test_managed_programs_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Roadmap: DEV-091

from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from rest_framework.test import APIClient

from partner_programs.tests.helpers import create_partner_program, create_user


class ManagedProgramListAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.manager = create_user(prefix="managed-program-manager")
self.other_manager = create_user(prefix="managed-program-other-manager")
self.participant = create_user(prefix="managed-program-participant")
self.staff = create_user(prefix="managed-program-staff", is_staff=True)
self.superuser = create_user(
prefix="managed-program-superuser",
is_staff=True,
is_superuser=True,
)
self.published_program = create_partner_program(
name="Янтарная программа",
draft=False,
)
self.draft_program = create_partner_program(
name="Альфа программа",
draft=True,
)
self.other_program = create_partner_program(
name="Бета программа",
draft=False,
)
self.published_program.managers.add(self.manager)
self.draft_program.managers.add(self.manager)
self.other_program.managers.add(self.other_manager)
self.url = reverse("partner_programs:managed-program-list")

def get_as(self, user):
self.client.force_authenticate(user=user)
return self.client.get(self.url)

def test_manager_sees_only_own_programs_including_drafts(self):
response = self.get_as(self.manager)

self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
[
{
"id": self.draft_program.pk,
"name": "Альфа программа",
"draft": True,
},
{
"id": self.published_program.pk,
"name": "Янтарная программа",
"draft": False,
},
],
)

def test_programs_of_another_manager_are_not_disclosed(self):
response = self.get_as(self.manager)

returned_ids = {program["id"] for program in response.data}
self.assertNotIn(self.other_program.pk, returned_ids)

def test_participant_without_managed_programs_gets_empty_list(self):
response = self.get_as(self.participant)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, [])

def test_staff_and_superuser_see_all_programs(self):
expected_ids = [
self.draft_program.pk,
self.other_program.pk,
self.published_program.pk,
]
for user in (self.staff, self.superuser):
with self.subTest(user=user.email):
response = self.get_as(user)

self.assertEqual(response.status_code, 200)
self.assertEqual(
[program["id"] for program in response.data],
expected_ids,
)

def test_anonymous_user_gets_401(self):
response = self.client.get(self.url)

self.assertEqual(response.status_code, 401)

def test_order_is_stable_for_programs_with_same_name(self):
first = create_partner_program(name="Одинаковое имя")
second = create_partner_program(name="Одинаковое имя")
first.managers.add(self.manager)
second.managers.add(self.manager)

response = self.get_as(self.manager)

same_name_ids = [
program["id"]
for program in response.data
if program["name"] == "Одинаковое имя"
]
self.assertEqual(same_name_ids, [first.pk, second.pk])

def test_program_count_does_not_increase_query_count(self):
for index in range(10):
program = create_partner_program(
name="Масштаб {}".format(str(index).zfill(2))
)
program.managers.add(self.manager)
self.client.force_authenticate(user=self.manager)

with CaptureQueriesContext(connection) as queries:
response = self.client.get(self.url)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(queries), 1)
25 changes: 24 additions & 1 deletion partner_programs/tests/test_manager_program_overview_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Roadmap: DEV-076, DEV-056
# Roadmap: DEV-076, DEV-056, DEV-091

import json

Expand Down Expand Up @@ -264,6 +264,7 @@ def test_empty_program_returns_full_zero_contract(self):
{
"program": {"id": self.program.pk, "name": "Overview program"},
"registrations": {"total": 0},
"participants": {"total": 0},
"applications": {
"total": 0,
"by_status": {
Expand Down Expand Up @@ -314,6 +315,7 @@ def test_all_domain_counters_follow_the_contract(self):

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["registrations"], {"total": 2})
self.assertEqual(response.data["participants"], {"total": 2})
self.assertEqual(response.data["applications"]["total"], 6)
self.assertEqual(
response.data["applications"]["by_status"],
Expand Down Expand Up @@ -374,12 +376,33 @@ def test_other_program_data_does_not_change_counters(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["program"]["id"], self.program.pk)
self.assertEqual(response.data["registrations"]["total"], 2)
self.assertEqual(response.data["participants"]["total"], 2)
self.assertEqual(response.data["applications"]["total"], 6)
self.assertEqual(response.data["teams"]["total"], 1)
self.assertEqual(response.data["submissions"]["total"], 5)
self.assertEqual(response.data["expert_assignments"]["total"], 3)
self.assertEqual(response.data["evaluations"]["total"], 2)

def test_participants_count_only_non_null_unique_users(self):
for index in range(2):
PartnerProgramUserProfile.objects.create(
user=create_user(prefix=f"overview-unique-participant-{index}"),
partner_program=self.program,
partner_program_data={"registration": index},
)
for index in range(2):
PartnerProgramUserProfile.objects.create(
user=None,
partner_program=self.program,
partner_program_data={"deleted_user_registration": index},
)

response = self.get_as(self.manager)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["registrations"], {"total": 4})
self.assertEqual(response.data["participants"], {"total": 2})

def test_response_does_not_contain_pii_or_private_content(self):
self.seed_complete_overview(self.program, self.manager, "overview-private")

Expand Down
10 changes: 9 additions & 1 deletion partner_programs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
ProgramEvaluationDetailView,
ProgramEvaluationListView,
)
from partner_programs.manager_overview_views import ManagerProgramOverviewView
from partner_programs.manager_overview_views import (
ManagedProgramListView,
ManagerProgramOverviewView,
)
from partner_programs.submission_assignment_views import (
ProgramSubmissionAssignmentListCreateView,
)
Expand All @@ -36,6 +39,11 @@

urlpatterns = [
path("", PartnerProgramList.as_view()),
path(
"managed/",
ManagedProgramListView.as_view(),
name="managed-program-list",
),
path(
"<int:program_id>/manager-overview/",
ManagerProgramOverviewView.as_view(),
Expand Down
Loading