From 0d7dd1b255e584b42697d7946489ad3efe52853b Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 31 Aug 2026 15:48:36 +0300 Subject: [PATCH 1/2] Add program manager analytics API --- docs/program-manager-analytics-api.md | 99 ++++++ partner_programs/permissions.py | 10 +- partner_programs/serializers/__init__.py | 2 + partner_programs/serializers/analytics.py | 75 +++++ partner_programs/services/__init__.py | 2 + partner_programs/services/analytics.py | 250 ++++++++++++++ .../tests/test_manager_analytics_api.py | 305 ++++++++++++++++++ partner_programs/urls.py | 6 + partner_programs/views.py | 20 ++ 9 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 docs/program-manager-analytics-api.md create mode 100644 partner_programs/serializers/analytics.py create mode 100644 partner_programs/services/analytics.py create mode 100644 partner_programs/tests/test_manager_analytics_api.py diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md new file mode 100644 index 00000000..51310921 --- /dev/null +++ b/docs/program-manager-analytics-api.md @@ -0,0 +1,99 @@ +# Аналитика партнёрской программы + +## Endpoint и доступ + +`GET /programs//manager-overview/` + +Endpoint доступен менеджерам указанной программы, staff и superuser. Для +авторизованного пользователя без этих прав возвращается `403`, для неизвестной +программы — `404`. + +## Контракт + +```json +{ + "summary": { + "participants": {"total": 3}, + "projects": {"total": 2}, + "experts": {"total": 2}, + "regions": { + "total": 1, + "items": [{"name": "Москва", "count": 2}] + } + }, + "participant_funnel": { + "registrations": 4, + "unique_participants": 3, + "with_team": 2, + "project_creators": 1, + "submitted_project_creators": 1 + }, + "solution_funnel": { + "created": 2, + "not_submitted": 0, + "submitted": 2, + "evaluated": 1 + }, + "evaluation_status": { + "required_evaluations_per_project": 2, + "assignments": {"total": 3, "pending": 1, "evaluated": 2}, + "projects": { + "submitted": 2, + "awaiting_first_evaluation": 0, + "requiring_additional_evaluations": 1, + "evaluated": 1 + } + }, + "attention": { + "participants_without_team": 1, + "projects_awaiting_evaluation": 1 + }, + "activity": [ + { + "date": "2026-08-01", + "registrations": 2, + "submitted_solutions": 1 + } + ] +} +``` + +## Семантика метрик + +- `registrations` — количество регистрационных записей + `PartnerProgramUserProfile`, включая сохранённые записи с удалённым + пользователем. +- `unique_participants` и `summary.participants.total` — уникальные ненулевые + `PartnerProgramUserProfile.user_id`. +- Участник считается состоящим в команде, если он является руководителем либо + `Collaborator` проекта, связанного с программой через + `PartnerProgramProject`. Поле `PartnerProgramUserProfile.project` не + используется как единственный источник состава команды. +- `project_creators` — уникальные зарегистрированные участники, являющиеся + руководителями связанных с программой проектов. +- Решение программы — `PartnerProgramProject`. Состояние сдачи определяется + только его полями `submitted` и `datetime_submitted`. +- Проект считается оценённым, когда число уникальных пользователей с + `ProjectScore` по критериям программы достигает `max_project_rates`. Если + `max_project_rates` не задан, требуется одна оценка. +- Назначение считается оценённым, если назначенный эксперт сохранил хотя бы + один `ProjectScore` этого проекта по критерию текущей программы. +- `projects_awaiting_evaluation` включает сданные проекты без первой оценки и + сданные проекты, которым не хватает оценок до настроенного количества. +- `summary.experts.total` — уникальные `expert_id` в + `ProjectExpertAssignment`, а не количество назначений. +- Регионы строятся по непустому `Project.region` связанных проектов. Пробелы по + краям удаляются; `items` сортируется по убыванию количества, затем по имени. +- `activity` всегда содержит последние 30 календарных дней, включая текущий. + Пропущенные даты заполняются нулями. Регистрации группируются по + `PartnerProgramUserProfile.datetime_created`, сдачи — по + `PartnerProgramProject.datetime_submitted`. + +## Кейсы + +В текущей модели нет отдельной сущности или обязательной связи «кейс». +Произвольные `PartnerProgramField` могут иметь похожее название, но не являются +стабильным системным контрактом. Поэтому `cases` в ответ не добавляется. +Для такой аналитики нужна отдельная модель кейса и явная внешняя связь +`PartnerProgramProject` с выбранным кейсом либо утверждённое системное поле с +гарантированным идентификатором. diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index 33c6a28c..e97fa2e5 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -3,6 +3,14 @@ from partner_programs.models import PartnerProgram +def can_manage_program(user, program: PartnerProgram) -> bool: + if not user or not user.is_authenticated: + return False + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + return program.is_manager(user) + + class IsProjectLeader(BasePermission): def has_object_permission(self, request, view, obj): return obj.project.leader == request.user @@ -54,4 +62,4 @@ def has_permission(self, request, view): except PartnerProgram.DoesNotExist: return False - return program.is_manager(user) + return can_manage_program(user, program) diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 42b2f311..c4ceb33d 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -1,3 +1,4 @@ +from .analytics import ProgramManagerAnalyticsSerializer from .fields import PartnerProgramFieldValueUpdateSerializer from .programs import ( PartnerProgramBaseSerializerMixin, @@ -26,6 +27,7 @@ "PartnerProgramForUnregisteredUserSerializer", "PartnerProgramListSerializer", "PartnerProgramMaterialSerializer", + "ProgramManagerAnalyticsSerializer", "PartnerProgramNewUserSerializer", "PartnerProgramProjectApplySerializer", "PartnerProgramUserSerializer", diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py new file mode 100644 index 00000000..716bdf6e --- /dev/null +++ b/partner_programs/serializers/analytics.py @@ -0,0 +1,75 @@ +from rest_framework import serializers + + +class AnalyticsTotalSerializer(serializers.Serializer): + total = serializers.IntegerField(min_value=0) + + +class AnalyticsRegionItemSerializer(serializers.Serializer): + name = serializers.CharField() + count = serializers.IntegerField(min_value=0) + + +class AnalyticsRegionsSerializer(AnalyticsTotalSerializer): + items = AnalyticsRegionItemSerializer(many=True) + + +class ProgramAnalyticsSummarySerializer(serializers.Serializer): + participants = AnalyticsTotalSerializer() + projects = AnalyticsTotalSerializer() + experts = AnalyticsTotalSerializer() + regions = AnalyticsRegionsSerializer() + + +class ProgramParticipantFunnelSerializer(serializers.Serializer): + registrations = serializers.IntegerField(min_value=0) + unique_participants = serializers.IntegerField(min_value=0) + with_team = serializers.IntegerField(min_value=0) + project_creators = serializers.IntegerField(min_value=0) + submitted_project_creators = serializers.IntegerField(min_value=0) + + +class ProgramSolutionFunnelSerializer(serializers.Serializer): + created = serializers.IntegerField(min_value=0) + not_submitted = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + evaluated = serializers.IntegerField(min_value=0) + + +class ProgramAssignmentEvaluationSerializer(serializers.Serializer): + total = serializers.IntegerField(min_value=0) + pending = serializers.IntegerField(min_value=0) + evaluated = serializers.IntegerField(min_value=0) + + +class ProgramProjectEvaluationSerializer(serializers.Serializer): + submitted = serializers.IntegerField(min_value=0) + awaiting_first_evaluation = serializers.IntegerField(min_value=0) + requiring_additional_evaluations = serializers.IntegerField(min_value=0) + evaluated = serializers.IntegerField(min_value=0) + + +class ProgramEvaluationStatusSerializer(serializers.Serializer): + required_evaluations_per_project = serializers.IntegerField(min_value=1) + assignments = ProgramAssignmentEvaluationSerializer() + projects = ProgramProjectEvaluationSerializer() + + +class ProgramAttentionSerializer(serializers.Serializer): + participants_without_team = serializers.IntegerField(min_value=0) + projects_awaiting_evaluation = serializers.IntegerField(min_value=0) + + +class ProgramActivityItemSerializer(serializers.Serializer): + date = serializers.DateField() + registrations = serializers.IntegerField(min_value=0) + submitted_solutions = serializers.IntegerField(min_value=0) + + +class ProgramManagerAnalyticsSerializer(serializers.Serializer): + summary = ProgramAnalyticsSummarySerializer() + participant_funnel = ProgramParticipantFunnelSerializer() + solution_funnel = ProgramSolutionFunnelSerializer() + evaluation_status = ProgramEvaluationStatusSerializer() + attention = ProgramAttentionSerializer() + activity = ProgramActivityItemSerializer(many=True) diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index 828cde87..323c2fc3 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -1,3 +1,4 @@ +from partner_programs.services.analytics import build_program_manager_analytics from partner_programs.services.exports import ( BASE_COLUMNS, ProgramExportFile, @@ -28,6 +29,7 @@ ) __all__ = [ + "build_program_manager_analytics", "BASE_COLUMNS", "ProgramExportFile", "ProgramProjectAlreadyApplied", diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py new file mode 100644 index 00000000..80b45073 --- /dev/null +++ b/partner_programs/services/analytics.py @@ -0,0 +1,250 @@ +from datetime import timedelta + +from django.db.models import Count, Exists, OuterRef, Q +from django.db.models.functions import TruncDate, Trim +from django.utils import timezone + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from projects.models import Collaborator +from project_rates.models import ProjectExpertAssignment, ProjectScore + +ACTIVITY_DAYS = 30 + + +def _get_participant_metrics(program_id: int) -> dict[str, int]: + leader_exists = Exists( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=OuterRef("user_id"), + ) + ) + submitted_leader_exists = Exists( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=OuterRef("user_id"), + submitted=True, + ) + ) + collaborator_exists = Exists( + Collaborator.objects.filter( + user_id=OuterRef("user_id"), + project__program_links__partner_program_id=program_id, + ) + ) + profiles = PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id + ).annotate( + is_project_leader=leader_exists, + is_submitted_project_leader=submitted_leader_exists, + is_project_collaborator=collaborator_exists, + ) + participant_filter = Q(user_id__isnull=False) + team_filter = Q(is_project_leader=True) | Q(is_project_collaborator=True) + + return profiles.aggregate( + registrations=Count("id"), + unique_participants=Count( + "user_id", + filter=participant_filter, + distinct=True, + ), + with_team=Count( + "user_id", + filter=participant_filter & team_filter, + distinct=True, + ), + without_team=Count( + "user_id", + filter=( + participant_filter + & Q(is_project_leader=False) + & Q(is_project_collaborator=False) + ), + distinct=True, + ), + project_creators=Count( + "user_id", + filter=participant_filter & Q(is_project_leader=True), + distinct=True, + ), + submitted_project_creators=Count( + "user_id", + filter=participant_filter & Q(is_submitted_project_leader=True), + distinct=True, + ), + ) + + +def _get_regions(program_id: int) -> list[dict]: + return list( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__region__isnull=False, + ) + .annotate(name=Trim("project__region")) + .exclude(name="") + .values("name") + .annotate(count=Count("project_id", distinct=True)) + .order_by("-count", "name") + ) + + +def _get_solution_metrics( + program_id: int, + required_evaluations: int, +) -> dict[str, int]: + project_rows = ( + PartnerProgramProject.objects.filter(partner_program_id=program_id) + .annotate( + rated_experts=Count( + "project__scores__user_id", + filter=Q( + project__scores__criteria__partner_program_id=program_id, + ), + distinct=True, + ) + ) + .values_list("submitted", "rated_experts") + ) + + metrics = { + "created": 0, + "not_submitted": 0, + "submitted": 0, + "awaiting_first_evaluation": 0, + "requiring_additional_evaluations": 0, + "evaluated": 0, + } + for submitted, rated_experts in project_rows: + metrics["created"] += 1 + if not submitted: + metrics["not_submitted"] += 1 + continue + + metrics["submitted"] += 1 + if rated_experts == 0: + metrics["awaiting_first_evaluation"] += 1 + elif rated_experts < required_evaluations: + metrics["requiring_additional_evaluations"] += 1 + else: + metrics["evaluated"] += 1 + + return metrics + + +def _get_assignment_metrics(program_id: int) -> dict[str, int]: + score_exists = Exists( + ProjectScore.objects.filter( + project_id=OuterRef("project_id"), + user_id=OuterRef("expert__user_id"), + criteria__partner_program_id=program_id, + ) + ) + metrics = ( + ProjectExpertAssignment.objects.filter(partner_program_id=program_id) + .annotate(has_score=score_exists) + .aggregate( + total=Count("id"), + evaluated=Count("id", filter=Q(has_score=True)), + unique_experts=Count("expert_id", distinct=True), + ) + ) + metrics["pending"] = metrics["total"] - metrics["evaluated"] + return metrics + + +def _get_activity(program_id: int) -> list[dict]: + today = timezone.localdate() + start_date = today - timedelta(days=ACTIVITY_DAYS - 1) + + registrations = dict( + PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + datetime_created__date__gte=start_date, + datetime_created__date__lte=today, + ) + .annotate(activity_date=TruncDate("datetime_created")) + .values("activity_date") + .annotate(total=Count("id")) + .values_list("activity_date", "total") + ) + submissions = dict( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + submitted=True, + datetime_submitted__date__gte=start_date, + datetime_submitted__date__lte=today, + ) + .annotate(activity_date=TruncDate("datetime_submitted")) + .values("activity_date") + .annotate(total=Count("id")) + .values_list("activity_date", "total") + ) + + return [ + { + "date": activity_date, + "registrations": registrations.get(activity_date, 0), + "submitted_solutions": submissions.get(activity_date, 0), + } + for activity_date in ( + start_date + timedelta(days=offset) for offset in range(ACTIVITY_DAYS) + ) + ] + + +def build_program_manager_analytics(program) -> dict: + program_id = program.id + required_evaluations = program.max_project_rates or 1 + participants = _get_participant_metrics(program_id) + regions = _get_regions(program_id) + solutions = _get_solution_metrics(program_id, required_evaluations) + assignments = _get_assignment_metrics(program_id) + + projects_awaiting_evaluation = ( + solutions["awaiting_first_evaluation"] + + solutions["requiring_additional_evaluations"] + ) + + return { + "summary": { + "participants": {"total": participants["unique_participants"]}, + "projects": {"total": solutions["created"]}, + "experts": {"total": assignments["unique_experts"]}, + "regions": {"total": len(regions), "items": regions}, + }, + "participant_funnel": { + "registrations": participants["registrations"], + "unique_participants": participants["unique_participants"], + "with_team": participants["with_team"], + "project_creators": participants["project_creators"], + "submitted_project_creators": participants["submitted_project_creators"], + }, + "solution_funnel": { + "created": solutions["created"], + "not_submitted": solutions["not_submitted"], + "submitted": solutions["submitted"], + "evaluated": solutions["evaluated"], + }, + "evaluation_status": { + "required_evaluations_per_project": required_evaluations, + "assignments": { + "total": assignments["total"], + "pending": assignments["pending"], + "evaluated": assignments["evaluated"], + }, + "projects": { + "submitted": solutions["submitted"], + "awaiting_first_evaluation": solutions["awaiting_first_evaluation"], + "requiring_additional_evaluations": solutions[ + "requiring_additional_evaluations" + ], + "evaluated": solutions["evaluated"], + }, + }, + "attention": { + "participants_without_team": participants["without_team"], + "projects_awaiting_evaluation": projects_awaiting_evaluation, + }, + "activity": _get_activity(program_id), + } diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py new file mode 100644 index 00000000..fe0b419c --- /dev/null +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -0,0 +1,305 @@ +from datetime import timedelta + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ProgramManagerAnalyticsAccessTests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="analytics-manager") + self.program = create_partner_program() + self.program.managers.add(self.manager) + self.url = reverse( + "partner_programs:manager-overview", + kwargs={"pk": self.program.id}, + ) + + def test_manager_can_open_analytics(self): + self.client.force_authenticate(self.manager) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + + def test_staff_and_superuser_can_open_analytics(self): + for user in ( + create_user(prefix="analytics-staff", is_staff=True), + create_user(prefix="analytics-superuser", is_superuser=True), + ): + with self.subTest(user=user): + self.client.force_authenticate(user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_participant_cannot_open_analytics(self): + participant = create_user(prefix="analytics-participant") + create_program_member(self.program, user=participant) + self.client.force_authenticate(participant) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 403) + + def test_anonymous_user_cannot_open_analytics(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + + def test_missing_program_returns_not_found(self): + staff = create_user(prefix="analytics-missing-staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.get( + reverse("partner_programs:manager-overview", kwargs={"pk": 999999}) + ) + + self.assertEqual(response.status_code, 404) + + +class ProgramManagerAnalyticsMetricsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="analytics-metrics-manager") + self.program = create_partner_program(max_project_rates=2) + self.program.managers.add(self.manager) + self.client.force_authenticate(self.manager) + self.url = reverse( + "partner_programs:manager-overview", + kwargs={"pk": self.program.id}, + ) + + def test_summary_participant_funnel_and_regions_use_real_relations(self): + leader = create_user(prefix="analytics-leader") + collaborator = create_user(prefix="analytics-collaborator") + participant_without_team = create_user(prefix="analytics-no-team") + for user in (leader, collaborator, participant_without_team): + create_program_member(self.program, user=user) + PartnerProgramUserProfile.objects.create( + user=None, + partner_program=self.program, + partner_program_data={}, + ) + + project = create_project(leader=leader, region=" Moscow ") + create_program_project(self.program, project=project, submitted=True) + Collaborator.objects.create(user=collaborator, project=project) + second_project = create_project(region="Moscow") + create_program_project(self.program, project=second_project) + + first_expert = create_rate_expert( + prefix="analytics-summary-expert-1", + program=self.program, + ) + second_expert = create_rate_expert( + prefix="analytics-summary-expert-2", + program=self.program, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=project, + expert=first_expert.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=second_project, + expert=first_expert.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=project, + expert=second_expert.expert, + ) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["summary"], + { + "participants": {"total": 3}, + "projects": {"total": 2}, + "experts": {"total": 2}, + "regions": { + "total": 1, + "items": [{"name": "Moscow", "count": 2}], + }, + }, + ) + self.assertEqual( + response.data["participant_funnel"], + { + "registrations": 4, + "unique_participants": 3, + "with_team": 2, + "project_creators": 1, + "submitted_project_creators": 1, + }, + ) + self.assertEqual(response.data["attention"]["participants_without_team"], 1) + + def test_solution_and_evaluation_metrics_use_scores_from_distinct_experts(self): + evaluated_project = create_project(name="Evaluated") + partial_project = create_project(name="Partially evaluated") + pending_project = create_project(name="Pending") + draft_solution = create_project(name="Not submitted") + for project, submitted in ( + (evaluated_project, True), + (partial_project, True), + (pending_project, True), + (draft_solution, False), + ): + create_program_project( + self.program, + project=project, + submitted=submitted, + ) + + first_expert = create_rate_expert( + prefix="analytics-evaluation-expert-1", + program=self.program, + ) + second_expert = create_rate_expert( + prefix="analytics-evaluation-expert-2", + program=self.program, + ) + for project, expert in ( + (evaluated_project, first_expert), + (evaluated_project, second_expert), + (partial_project, first_expert), + (partial_project, second_expert), + (pending_project, first_expert), + ): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=project, + expert=expert.expert, + ) + + criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + for project, expert in ( + (evaluated_project, first_expert), + (evaluated_project, second_expert), + (partial_project, first_expert), + ): + ProjectScore.objects.create( + criteria=criteria, + user=expert, + project=project, + value="8", + ) + + response = self.client.get(self.url) + + self.assertEqual( + response.data["solution_funnel"], + { + "created": 4, + "not_submitted": 1, + "submitted": 3, + "evaluated": 1, + }, + ) + self.assertEqual( + response.data["evaluation_status"], + { + "required_evaluations_per_project": 2, + "assignments": {"total": 5, "pending": 2, "evaluated": 3}, + "projects": { + "submitted": 3, + "awaiting_first_evaluation": 1, + "requiring_additional_evaluations": 1, + "evaluated": 1, + }, + }, + ) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 2) + + def test_activity_groups_events_and_fills_thirty_day_range(self): + participant = create_program_member(self.program) + submitted_link = create_program_project( + self.program, + submitted=True, + ) + event_datetime = timezone.now() - timedelta(days=2) + PartnerProgramUserProfile.objects.filter(pk=participant.pk).update( + datetime_created=event_datetime + ) + type(submitted_link).objects.filter(pk=submitted_link.pk).update( + datetime_submitted=event_datetime + ) + + response = self.client.get(self.url) + + activity = response.data["activity"] + event_date = timezone.localdate(event_datetime).isoformat() + event_item = next(item for item in activity if item["date"] == event_date) + self.assertEqual(len(activity), 30) + self.assertEqual(event_item["registrations"], 1) + self.assertEqual(event_item["submitted_solutions"], 1) + self.assertTrue( + any( + item["registrations"] == 0 and item["submitted_solutions"] == 0 + for item in activity + ) + ) + + def test_empty_program_returns_zero_metrics_and_continuous_activity(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["summary"]["participants"]["total"], 0) + self.assertEqual(response.data["summary"]["projects"]["total"], 0) + self.assertEqual(response.data["summary"]["experts"]["total"], 0) + self.assertEqual(response.data["summary"]["regions"], {"total": 0, "items": []}) + self.assertEqual(len(response.data["activity"]), 30) + self.assertTrue( + all( + item["registrations"] == 0 and item["submitted_solutions"] == 0 + for item in response.data["activity"] + ) + ) + self.assertNotIn("cases", response.data) + + def test_query_count_does_not_grow_with_program_size(self): + with CaptureQueriesContext(connection) as empty_context: + empty_response = self.client.get(self.url) + self.assertEqual(empty_response.status_code, 200) + + for index in range(12): + participant = create_user(prefix=f"analytics-query-participant-{index}") + create_program_member(self.program, user=participant) + create_program_project( + self.program, + project=create_project(leader=participant, region=f"Region {index}"), + submitted=index % 2 == 0, + ) + + with CaptureQueriesContext(connection) as populated_context: + populated_response = self.client.get(self.url) + self.assertEqual(populated_response.status_code, 200) + + self.assertEqual(len(populated_context), len(empty_context)) + self.assertLessEqual(len(populated_context), 10) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index baf2008c..a9e14b0b 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -8,6 +8,7 @@ PartnerProgramExportProjectsAPIView, PartnerProgramExportRatesAPIView, PartnerProgramList, + ProgramManagerAnalyticsAPIView, PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, @@ -24,6 +25,11 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), path("/", PartnerProgramDetail.as_view()), + path( + "/manager-overview/", + ProgramManagerAnalyticsAPIView.as_view(), + name="manager-overview", + ), path( "/acknowledge-welcome/", PartnerProgramWelcomeAcknowledgement.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 5b3e1826..885ad76e 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -26,6 +26,7 @@ from partner_programs.permissions import ( IsAdminOrManagerOfProgram, IsProjectLeader, + can_manage_program, ) from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, @@ -37,6 +38,7 @@ PartnerProgramProjectApplySerializer, PartnerProgramUserSerializer, ProgramProjectFilterRequestSerializer, + ProgramManagerAnalyticsSerializer, ) from partner_programs.services import ( ProgramProjectAlreadyApplied, @@ -49,6 +51,7 @@ get_filterable_program_fields, get_filtered_program_project_links, register_user_to_program, + build_program_manager_analytics, require_can_apply_project_to_program, ) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer @@ -424,6 +427,23 @@ def get(self, request, pk): return Response(serializer.data) +class ProgramManagerAnalyticsAPIView(APIView): + """Aggregated program analytics for managers and administrators.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, pk: int): + program = get_object_or_404(PartnerProgram, pk=pk) + if not can_manage_program(request.user, program): + raise PermissionDenied("Недостаточно прав.") + + serializer = ProgramManagerAnalyticsSerializer( + data=build_program_manager_analytics(program) + ) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] From e3fc238c053bc94e7a4682aa0eed12b9e354f0b9 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 31 Aug 2026 17:14:38 +0300 Subject: [PATCH 2/2] Fix program analytics evaluation semantics --- docs/program-manager-analytics-api.md | 29 ++-- partner_programs/serializers/analytics.py | 10 +- partner_programs/services/analytics.py | 74 ++++---- .../tests/test_manager_analytics_api.py | 159 +++++++++++++++++- 4 files changed, 220 insertions(+), 52 deletions(-) diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 51310921..b7baddfe 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -35,12 +35,13 @@ Endpoint доступен менеджерам указанной програм "evaluated": 1 }, "evaluation_status": { - "required_evaluations_per_project": 2, + "mode": "distributed", + "max_evaluations_per_project": 2, "assignments": {"total": 3, "pending": 1, "evaluated": 2}, "projects": { "submitted": 2, - "awaiting_first_evaluation": 0, - "requiring_additional_evaluations": 1, + "awaiting_evaluation": 0, + "partially_evaluated": 1, "evaluated": 1 } }, @@ -73,15 +74,23 @@ Endpoint доступен менеджерам указанной програм руководителями связанных с программой проектов. - Решение программы — `PartnerProgramProject`. Состояние сдачи определяется только его полями `submitted` и `datetime_submitted`. -- Проект считается оценённым, когда число уникальных пользователей с - `ProjectScore` по критериям программы достигает `max_project_rates`. Если - `max_project_rates` не задан, требуется одна оценка. +- `max_evaluations_per_project` возвращает `PartnerProgram.max_project_rates`: + это верхний лимит числа оценивающих экспертов, а не обязательное количество + оценок. Значение может быть `null` и не определяет статус проекта. +- В открытом режиме (`mode=open`) назначения не обязательны. Сданный проект + считается оценённым после первой оценки любого уникального эксперта по + критериям программы; до первой оценки он ожидает оценивания. +- В распределённом режиме (`mode=distributed`) ожидаемые оценки определяются + `ProjectExpertAssignment`. Проект без назначений либо без выполненных + назначений ожидает оценивания; проект с частью выполненных назначений имеет + статус `partially_evaluated`; при выполнении всех назначений — `evaluated`. - Назначение считается оценённым, если назначенный эксперт сохранил хотя бы один `ProjectScore` этого проекта по критерию текущей программы. -- `projects_awaiting_evaluation` включает сданные проекты без первой оценки и - сданные проекты, которым не хватает оценок до настроенного количества. -- `summary.experts.total` — уникальные `expert_id` в - `ProjectExpertAssignment`, а не количество назначений. +- В открытом режиме `projects_awaiting_evaluation` включает только сданные + проекты без оценки. В распределённом режиме он включает ожидающие и частично + оценённые проекты. +- `summary.experts.total` — число уникальных `Expert`, состоящих в программе + через `Expert.programs`, независимо от наличия назначений. - Регионы строятся по непустому `Project.region` связанных проектов. Пробелы по краям удаляются; `items` сортируется по убыванию количества, затем по имени. - `activity` всегда содержит последние 30 календарных дней, включая текущий. diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 716bdf6e..8414809c 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -44,13 +44,17 @@ class ProgramAssignmentEvaluationSerializer(serializers.Serializer): class ProgramProjectEvaluationSerializer(serializers.Serializer): submitted = serializers.IntegerField(min_value=0) - awaiting_first_evaluation = serializers.IntegerField(min_value=0) - requiring_additional_evaluations = serializers.IntegerField(min_value=0) + awaiting_evaluation = serializers.IntegerField(min_value=0) + partially_evaluated = serializers.IntegerField(min_value=0) evaluated = serializers.IntegerField(min_value=0) class ProgramEvaluationStatusSerializer(serializers.Serializer): - required_evaluations_per_project = serializers.IntegerField(min_value=1) + mode = serializers.ChoiceField(choices=("open", "distributed")) + max_evaluations_per_project = serializers.IntegerField( + min_value=1, + allow_null=True, + ) assignments = ProgramAssignmentEvaluationSerializer() projects = ProgramProjectEvaluationSerializer() diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 80b45073..2700a382 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import timedelta from django.db.models import Count, Exists, OuterRef, Q @@ -89,10 +90,8 @@ def _get_regions(program_id: int) -> list[dict]: ) -def _get_solution_metrics( - program_id: int, - required_evaluations: int, -) -> dict[str, int]: +def _get_solution_metrics(program, assignments_by_project: dict) -> dict[str, int]: + program_id = program.id project_rows = ( PartnerProgramProject.objects.filter(partner_program_id=program_id) .annotate( @@ -104,35 +103,46 @@ def _get_solution_metrics( distinct=True, ) ) - .values_list("submitted", "rated_experts") + .values_list("project_id", "submitted", "rated_experts") ) metrics = { "created": 0, "not_submitted": 0, "submitted": 0, - "awaiting_first_evaluation": 0, - "requiring_additional_evaluations": 0, + "awaiting_evaluation": 0, + "partially_evaluated": 0, "evaluated": 0, } - for submitted, rated_experts in project_rows: + for project_id, submitted, rated_experts in project_rows: metrics["created"] += 1 if not submitted: metrics["not_submitted"] += 1 continue metrics["submitted"] += 1 - if rated_experts == 0: - metrics["awaiting_first_evaluation"] += 1 - elif rated_experts < required_evaluations: - metrics["requiring_additional_evaluations"] += 1 + if not program.is_distributed_evaluation: + status = "evaluated" if rated_experts > 0 else "awaiting_evaluation" + metrics[status] += 1 + continue + + project_assignments = assignments_by_project.get( + project_id, + {"total": 0, "evaluated": 0}, + ) + assigned = project_assignments["total"] + evaluated_assignments = project_assignments["evaluated"] + if assigned == 0 or evaluated_assignments == 0: + metrics["awaiting_evaluation"] += 1 + elif evaluated_assignments < assigned: + metrics["partially_evaluated"] += 1 else: metrics["evaluated"] += 1 return metrics -def _get_assignment_metrics(program_id: int) -> dict[str, int]: +def _get_assignment_metrics(program_id: int) -> tuple[dict[str, int], dict]: score_exists = Exists( ProjectScore.objects.filter( project_id=OuterRef("project_id"), @@ -140,17 +150,20 @@ def _get_assignment_metrics(program_id: int) -> dict[str, int]: criteria__partner_program_id=program_id, ) ) - metrics = ( + assignment_rows = ( ProjectExpertAssignment.objects.filter(partner_program_id=program_id) .annotate(has_score=score_exists) - .aggregate( - total=Count("id"), - evaluated=Count("id", filter=Q(has_score=True)), - unique_experts=Count("expert_id", distinct=True), - ) + .values_list("project_id", "has_score") ) - metrics["pending"] = metrics["total"] - metrics["evaluated"] - return metrics + metrics = {"total": 0, "pending": 0, "evaluated": 0} + by_project = defaultdict(lambda: {"total": 0, "evaluated": 0}) + for project_id, has_score in assignment_rows: + metrics["total"] += 1 + metrics["evaluated" if has_score else "pending"] += 1 + by_project[project_id]["total"] += 1 + if has_score: + by_project[project_id]["evaluated"] += 1 + return metrics, dict(by_project) def _get_activity(program_id: int) -> list[dict]: @@ -195,22 +208,20 @@ def _get_activity(program_id: int) -> list[dict]: def build_program_manager_analytics(program) -> dict: program_id = program.id - required_evaluations = program.max_project_rates or 1 participants = _get_participant_metrics(program_id) regions = _get_regions(program_id) - solutions = _get_solution_metrics(program_id, required_evaluations) - assignments = _get_assignment_metrics(program_id) + assignments, assignments_by_project = _get_assignment_metrics(program_id) + solutions = _get_solution_metrics(program, assignments_by_project) projects_awaiting_evaluation = ( - solutions["awaiting_first_evaluation"] - + solutions["requiring_additional_evaluations"] + solutions["awaiting_evaluation"] + solutions["partially_evaluated"] ) return { "summary": { "participants": {"total": participants["unique_participants"]}, "projects": {"total": solutions["created"]}, - "experts": {"total": assignments["unique_experts"]}, + "experts": {"total": program.experts.count()}, "regions": {"total": len(regions), "items": regions}, }, "participant_funnel": { @@ -227,7 +238,8 @@ def build_program_manager_analytics(program) -> dict: "evaluated": solutions["evaluated"], }, "evaluation_status": { - "required_evaluations_per_project": required_evaluations, + "mode": ("distributed" if program.is_distributed_evaluation else "open"), + "max_evaluations_per_project": program.max_project_rates, "assignments": { "total": assignments["total"], "pending": assignments["pending"], @@ -235,10 +247,8 @@ def build_program_manager_analytics(program) -> dict: }, "projects": { "submitted": solutions["submitted"], - "awaiting_first_evaluation": solutions["awaiting_first_evaluation"], - "requiring_additional_evaluations": solutions[ - "requiring_additional_evaluations" - ], + "awaiting_evaluation": solutions["awaiting_evaluation"], + "partially_evaluated": solutions["partially_evaluated"], "evaluated": solutions["evaluated"], }, }, diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index fe0b419c..a1151cc2 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -154,7 +154,7 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): ) self.assertEqual(response.data["attention"]["participants_without_team"], 1) - def test_solution_and_evaluation_metrics_use_scores_from_distinct_experts(self): + def test_open_evaluation_uses_any_score_instead_of_max_project_rates(self): evaluated_project = create_project(name="Evaluated") partial_project = create_project(name="Partially evaluated") pending_project = create_project(name="Pending") @@ -219,23 +219,168 @@ def test_solution_and_evaluation_metrics_use_scores_from_distinct_experts(self): "created": 4, "not_submitted": 1, "submitted": 3, - "evaluated": 1, + "evaluated": 2, }, ) self.assertEqual( response.data["evaluation_status"], { - "required_evaluations_per_project": 2, + "mode": "open", + "max_evaluations_per_project": 2, "assignments": {"total": 5, "pending": 2, "evaluated": 3}, "projects": { "submitted": 3, - "awaiting_first_evaluation": 1, - "requiring_additional_evaluations": 1, - "evaluated": 1, + "awaiting_evaluation": 1, + "partially_evaluated": 0, + "evaluated": 2, }, }, ) - self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 2) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 1) + + def test_open_evaluation_limit_is_informational_only(self): + self.program.max_project_rates = 3 + self.program.save(update_fields=["max_project_rates"]) + project = create_project(name="Open evaluation") + create_program_project(self.program, project=project, submitted=True) + expert = create_rate_expert( + prefix="analytics-open-expert", + program=self.program, + ) + criteria = Criteria.objects.create( + name="Open impact", + type="int", + partner_program=self.program, + ) + ProjectScore.objects.create( + criteria=criteria, + user=expert, + project=project, + value="8", + ) + + response = self.client.get(self.url) + + self.assertEqual(response.data["evaluation_status"]["mode"], "open") + self.assertEqual( + response.data["evaluation_status"]["max_evaluations_per_project"], + 3, + ) + self.assertEqual( + response.data["evaluation_status"]["projects"], + { + "submitted": 1, + "awaiting_evaluation": 0, + "partially_evaluated": 0, + "evaluated": 1, + }, + ) + self.assertEqual(response.data["solution_funnel"]["evaluated"], 1) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 0) + + def test_summary_experts_uses_program_membership_without_assignments(self): + create_rate_expert( + prefix="analytics-program-expert-1", + program=self.program, + ) + create_rate_expert( + prefix="analytics-program-expert-2", + program=self.program, + ) + + response = self.client.get(self.url) + + self.assertEqual(response.data["summary"]["experts"]["total"], 2) + self.assertEqual( + response.data["evaluation_status"]["assignments"]["total"], + 0, + ) + + def _create_distributed_evaluation(self, *, assignments: int, scores: int): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + project = create_project(name="Distributed evaluation") + create_program_project(self.program, project=project, submitted=True) + criteria = Criteria.objects.create( + name="Distributed impact", + type="int", + partner_program=self.program, + ) + experts = [ + create_rate_expert( + prefix=f"analytics-distributed-expert-{index}", + program=self.program, + ) + for index in range(assignments) + ] + for expert in experts: + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=project, + expert=expert.expert, + ) + for expert in experts[:scores]: + ProjectScore.objects.create( + criteria=criteria, + user=expert, + project=project, + value="8", + ) + + def test_distributed_project_without_assignments_is_awaiting_evaluation(self): + self._create_distributed_evaluation(assignments=0, scores=0) + + response = self.client.get(self.url) + + self.assertEqual(response.data["evaluation_status"]["mode"], "distributed") + self.assertEqual( + response.data["evaluation_status"]["projects"], + { + "submitted": 1, + "awaiting_evaluation": 1, + "partially_evaluated": 0, + "evaluated": 0, + }, + ) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 1) + + def test_distributed_assignments_without_scores_are_awaiting_evaluation(self): + self._create_distributed_evaluation(assignments=2, scores=0) + + response = self.client.get(self.url) + + self.assertEqual( + response.data["evaluation_status"]["projects"]["awaiting_evaluation"], + 1, + ) + self.assertEqual( + response.data["evaluation_status"]["assignments"], + {"total": 2, "pending": 2, "evaluated": 0}, + ) + + def test_distributed_project_with_one_of_two_scores_is_partial(self): + self._create_distributed_evaluation(assignments=2, scores=1) + + response = self.client.get(self.url) + + self.assertEqual( + response.data["evaluation_status"]["projects"]["partially_evaluated"], + 1, + ) + self.assertEqual(response.data["solution_funnel"]["evaluated"], 0) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 1) + + def test_distributed_project_with_all_assignment_scores_is_evaluated(self): + self._create_distributed_evaluation(assignments=2, scores=2) + + response = self.client.get(self.url) + + self.assertEqual( + response.data["evaluation_status"]["projects"]["evaluated"], + 1, + ) + self.assertEqual(response.data["solution_funnel"]["evaluated"], 1) + self.assertEqual(response.data["attention"]["projects_awaiting_evaluation"], 0) def test_activity_groups_events_and_fills_thirty_day_range(self): participant = create_program_member(self.program)