diff --git a/docs/manager-program-overview-api.md b/docs/manager-program-overview-api.md index b07b24b1..c1f1d760 100644 --- a/docs/manager-program-overview-api.md +++ b/docs/manager-program-overview-api.md @@ -1,4 +1,4 @@ - + # Manager program overview API @@ -22,6 +22,7 @@ GET /programs//manager-overview/ { "program": {"id": 1, "name": "Program"}, "registrations": {"total": 0}, + "participants": {"total": 0}, "applications": { "total": 0, "by_status": { @@ -65,7 +66,11 @@ GET /programs//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`. @@ -83,6 +88,33 @@ GET /programs//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`, ссылки на diff --git a/partner_programs/manager_overview_views.py b/partner_programs/manager_overview_views.py index 50090439..c241db8e 100644 --- a/partner_programs/manager_overview_views.py +++ b/partner_programs/manager_overview_views.py @@ -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 ( @@ -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") diff --git a/partner_programs/serializers/manager_overview.py b/partner_programs/serializers/manager_overview.py index d9b203d0..d48f8082 100644 --- a/partner_programs/serializers/manager_overview.py +++ b/partner_programs/serializers/manager_overview.py @@ -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() diff --git a/partner_programs/services/manager_overview.py b/partner_programs/services/manager_overview.py index 713d5cb2..b1c9889d 100644 --- a/partner_programs/services/manager_overview.py +++ b/partner_programs/services/manager_overview.py @@ -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"), @@ -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( diff --git a/partner_programs/tests/test_managed_programs_api.py b/partner_programs/tests/test_managed_programs_api.py new file mode 100644 index 00000000..d48dd9a2 --- /dev/null +++ b/partner_programs/tests/test_managed_programs_api.py @@ -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) diff --git a/partner_programs/tests/test_manager_program_overview_api.py b/partner_programs/tests/test_manager_program_overview_api.py index 7fdde711..1582e0d6 100644 --- a/partner_programs/tests/test_manager_program_overview_api.py +++ b/partner_programs/tests/test_manager_program_overview_api.py @@ -1,4 +1,4 @@ -# Roadmap: DEV-076, DEV-056 +# Roadmap: DEV-076, DEV-056, DEV-091 import json @@ -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": { @@ -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"], @@ -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") diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 54c11360..47a304ab 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -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, ) @@ -36,6 +39,11 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), + path( + "managed/", + ManagedProgramListView.as_view(), + name="managed-program-list", + ), path( "/manager-overview/", ManagerProgramOverviewView.as_view(),