diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index b7baddfe..b071cb28 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -19,6 +19,13 @@ Endpoint доступен менеджерам указанной програм "regions": { "total": 1, "items": [{"name": "Москва", "count": 2}] + }, + "participant_regions": { + "total": 2, + "items": [ + {"name": "Москва", "count": 2}, + {"name": "Набережные Челны", "count": 1} + ] } }, "participant_funnel": { @@ -93,6 +100,13 @@ Endpoint доступен менеджерам указанной програм через `Expert.programs`, независимо от наличия назначений. - Регионы строятся по непустому `Project.region` связанных проектов. Пробелы по краям удаляются; `items` сортируется по убыванию количества, затем по имени. +- `summary.participant_regions` строится по `User.city` той же группы уникальных + ненулевых пользователей программы, что используется для + `summary.participants.total`. Пустые и состоящие только из пробелов значения + исключаются. Пробелы по краям удаляются; одинаковые после этого строки + группируются, а `items` сортируется по убыванию количества, затем по имени. + Значения не приводятся к другому регистру, не исправляются и не сопоставляются + с каноническими регионами: legacy-строки остаются отдельными элементами. - `activity` всегда содержит последние 30 календарных дней, включая текущий. Пропущенные даты заполняются нулями. Регистрации группируются по `PartnerProgramUserProfile.datetime_created`, сдачи — по diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 8414809c..92ace270 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -19,6 +19,7 @@ class ProgramAnalyticsSummarySerializer(serializers.Serializer): projects = AnalyticsTotalSerializer() experts = AnalyticsTotalSerializer() regions = AnalyticsRegionsSerializer() + participant_regions = AnalyticsRegionsSerializer() class ProgramParticipantFunnelSerializer(serializers.Serializer): diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 2700a382..74494509 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -90,6 +90,21 @@ def _get_regions(program_id: int) -> list[dict]: ) +def _get_participant_regions(program_id: int) -> list[dict]: + return list( + PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + user_id__isnull=False, + user__city__isnull=False, + ) + .annotate(name=Trim("user__city")) + .exclude(name="") + .values("name") + .annotate(count=Count("user_id", distinct=True)) + .order_by("-count", "name") + ) + + def _get_solution_metrics(program, assignments_by_project: dict) -> dict[str, int]: program_id = program.id project_rows = ( @@ -210,6 +225,7 @@ def build_program_manager_analytics(program) -> dict: program_id = program.id participants = _get_participant_metrics(program_id) regions = _get_regions(program_id) + participant_regions = _get_participant_regions(program_id) assignments, assignments_by_project = _get_assignment_metrics(program_id) solutions = _get_solution_metrics(program, assignments_by_project) @@ -223,6 +239,10 @@ def build_program_manager_analytics(program) -> dict: "projects": {"total": solutions["created"]}, "experts": {"total": program.experts.count()}, "regions": {"total": len(regions), "items": regions}, + "participant_regions": { + "total": len(participant_regions), + "items": participant_regions, + }, }, "participant_funnel": { "registrations": participants["registrations"], diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index a1151cc2..f7030852 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -140,6 +140,7 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): "total": 1, "items": [{"name": "Moscow", "count": 2}], }, + "participant_regions": {"total": 0, "items": []}, }, ) self.assertEqual( @@ -154,6 +155,43 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): ) self.assertEqual(response.data["attention"]["participants_without_team"], 1) + def test_participant_regions_use_only_program_participants_and_preserve_legacy_values( + self, + ): + for prefix, city in ( + ("analytics-region-moscow-1", "Москва"), + ("analytics-region-moscow-2", " Москва "), + ("analytics-region-legacy-city", "Набережные Челны"), + ("analytics-region-legacy-typo", "Мсква"), + ("analytics-region-empty", ""), + ("analytics-region-whitespace", " "), + ("analytics-region-null", None), + ): + create_program_member( + self.program, + user=create_user(prefix=prefix, city=city), + ) + + other_program = create_partner_program(name="Other analytics program") + create_program_member( + other_program, + user=create_user(prefix="analytics-region-other-program", city="Москва"), + ) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + participant_regions = response.data["summary"]["participant_regions"] + self.assertEqual(participant_regions["total"], 3) + self.assertEqual( + {item["name"]: item["count"] for item in participant_regions["items"]}, + { + "Москва": 2, + "Набережные Челны": 1, + "Мсква": 1, + }, + ) + 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") @@ -419,6 +457,10 @@ def test_empty_program_returns_zero_metrics_and_continuous_activity(self): 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( + response.data["summary"]["participant_regions"], + {"total": 0, "items": []}, + ) self.assertEqual(len(response.data["activity"]), 30) self.assertTrue( all(