From 514a4e8f4edf49bfd57445e95e376672421a538a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 10 Oct 2026 20:12:36 +0300 Subject: [PATCH] feat(analytics): count participants who completed onboarding --- docs/program-manager-analytics-api.md | 12 +++++++ partner_programs/serializers/analytics.py | 1 + partner_programs/services/analytics.py | 6 ++++ .../tests/test_manager_analytics_api.py | 36 +++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 1e695621..8b00e653 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -33,6 +33,7 @@ Endpoint доступен менеджерам указанной програм "participant_funnel": { "registrations": 4, "unique_participants": 3, + "onboarded_participants": 2, "with_team": 2, "project_creators": 1, "submitted_project_creators": 1 @@ -85,6 +86,17 @@ Endpoint доступен менеджерам указанной програм пользователем. - `unique_participants` и `summary.participants.total` — уникальные ненулевые `PartnerProgramUserProfile.user_id`. +- `onboarded_participants` — уникальные ненулевые пользователи, зарегистрированные + в этой программе, у которых `User.onboarding_stage IS NULL`: завершён стартовый + сценарий платформы. Подтверждение почты (`is_active`) и произвольный запрос к API + (`last_activity`) не заменяют этот критерий. Онбординг позволяет пропускать шаги, + поэтому метрика не означает заполненный профиль или регулярное использование. + Это текущее состояние, а не событие с датой: определить, был ли онбординг пройден + до или после регистрации в программе, по этому полю нельзя. +- Этапы участников показывают текущее состояние одной группы зарегистрированных + пользователей, а не последовательную событийную конверсию: `with_team` может + превышать `onboarded_participants`, если команда появилась раньше. Значения + возвращаются без сортировки, ограничения или искусственного уменьшения. - Участник считается состоящим в команде, если он является руководителем либо `Collaborator` проекта, связанного с программой через `PartnerProgramProject`. Поле `PartnerProgramUserProfile.project` не diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 4bbac6c9..8b69c780 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -25,6 +25,7 @@ class ProgramAnalyticsSummarySerializer(serializers.Serializer): class ProgramParticipantFunnelSerializer(serializers.Serializer): registrations = serializers.IntegerField(min_value=0) unique_participants = serializers.IntegerField(min_value=0) + onboarded_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) diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index a7aca785..5159624a 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -94,6 +94,11 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]: filter=participant_filter, distinct=True, ), + onboarded_participants=Count( + "user_id", + filter=participant_filter & Q(user__onboarding_stage__isnull=True), + distinct=True, + ), with_team=Count( "user_id", filter=participant_filter & team_filter, @@ -351,6 +356,7 @@ def build_program_manager_analytics(program) -> dict: "participant_funnel": { "registrations": participants["registrations"], "unique_participants": participants["unique_participants"], + "onboarded_participants": participants["onboarded_participants"], "with_team": participants["with_team"], "project_creators": participants["project_creators"], "submitted_project_creators": participants["submitted_project_creators"], diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index 871314d7..547acd2e 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -148,6 +148,7 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): { "registrations": 4, "unique_participants": 3, + "onboarded_participants": 0, "with_team": 2, "project_creators": 1, "submitted_project_creators": 1, @@ -155,6 +156,41 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): ) self.assertEqual(response.data["attention"]["participants_without_team"], 1) + def test_onboarding_counts_only_registered_users_with_completed_onboarding(self): + completed = create_user(onboarding_stage=None, is_active=True) + email_verified = create_user(onboarding_stage=0, is_active=True) + started = create_user(onboarding_stage=2, is_active=True) + for user in (completed, email_verified, started): + create_program_member(self.program, user=user) + # A team can exist before onboarding is complete: do not clamp real counts. + for leader in (email_verified, started): + create_program_project(self.program, project=create_project(leader=leader)) + # Neither other programs, unregistered leaders, nor deleted users count. + create_program_member( + create_partner_program(), user=create_user(onboarding_stage=None) + ) + create_program_project( + self.program, + project=create_project(leader=create_user(onboarding_stage=None)), + ) + PartnerProgramUserProfile.objects.create( + user=None, partner_program=self.program, partner_program_data={} + ) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + funnel = response.data["participant_funnel"] + self.assertEqual(funnel["registrations"], 4) + self.assertEqual(funnel["unique_participants"], 3) + self.assertEqual(funnel["onboarded_participants"], 1) + self.assertEqual(funnel["with_team"], 2) + + started.onboarding_stage = None + started.save(update_fields=["onboarding_stage"]) + response = self.client.get(self.url) + self.assertEqual(response.data["participant_funnel"]["onboarded_participants"], 2) + def test_participant_regions_use_only_program_participants_and_preserve_legacy_values( self, ):