diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 1b947c2f..216b25f3 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, + "visited_participants": 2, "onboarded_participants": 2, "with_team": 2, "project_creators": 1, @@ -65,6 +66,7 @@ Endpoint доступен менеджерам указанной програм } }, "attention": { + "participants_without_visit": 1, "participants_without_onboarding": 1, "participants_without_team": 1, "projects_awaiting_evaluation": 1, @@ -90,21 +92,37 @@ Endpoint доступен менеджерам указанной програм | Пункт интерфейса | Условие | | --- | --- | -| Участники не завершили онбординг | Есть уникальные зарегистрированные пользователи с `onboarding_stage IS NOT NULL`, включая этап 0. Завершённый онбординг — `NULL`. Подтверждение почты и членство в команде не заменяют онбординг; временного порога нет. | +| Участники ещё не заходили на платформу | Уникальные зарегистрированные пользователи программы, у которых `last_activity IS NULL AND last_login IS NULL`. Ни онбординг, ни подтверждение почты, ни членство в команде не определяют посещение. Временного порога нет; посещения всей платформы до регистрации в программе также учитываются. | | Участники без команды | Зарегистрированный пользователь не является ни руководителем, ни `Collaborator` проекта, связанного с этой программой. Команда в другой программе не исключает пользователя; временного порога нет. | | Работы ожидают оценивания | Только сданные работы. В открытом режиме нет ни одной оценки по критериям программы. В распределённом режиме нет назначений либо хотя бы одно назначение ещё не завершено, включая частично оценённые работы. Временного порога нет. | | Эксперты задерживают оценивание | Только распределённое оценивание: задана `datetime_evaluation_ends`, наступил момент за 5 суток до неё (`now >= datetime_evaluation_ends - 5 days`), у эксперта есть хотя бы одно назначение со статусом `pending` или `in_progress` на сданную работу. До дедлайна — `warning`, начиная с дедлайна — `critical`; сигнал остаётся до завершения оценок. Время ожидания 24/48 часов не влияет на появление. Несданные работы и завершённые назначения не создают сигнал. | -Новый счётчик `attention.participants_without_onboarding` использует тот же +Счётчик `attention.participants_without_visit` использует тот же предикат, что и список -`GET /programs/{id}/manager-overview/participants-without-onboarding/`. +`GET /programs/{id}/manager-overview/participants-without-visit/`. Список поддерживает `search`, `limit` (1–100, по умолчанию 25), `offset`; возвращает стандартные `count`, `next`, `previous`, `results` с публичными полями участника. Права, поиск, порядок и пагинация совпадают со списком участников без команды. Удалённые пользователи и регистрации других программ исключены. -Равенство: `unique_participants = onboarded_participants + participants_without_onboarding`. +Равенство: `unique_participants = visited_participants + participants_without_visit`. Новая миграция БД не требуется. +Воронка интерфейса: «Зарегистрировались → Заходили на платформу → Вступили в команду». +`visited_participants` считает уникальных зарегистрированных пользователей с +`last_activity IS NOT NULL OR last_login IS NOT NULL`. Одновременное наличие обеих +дат не удваивает человека. Это факт хотя бы одного зафиксированного посещения +всей платформы, без ограничения по давности или дате регистрации в программе. +`last_activity` обновляется при JWT-аутентификации запросов с ограничением раз +в 15 минут; `last_login` сохраняет пользователей с записанным входом и пустой +активностью. Пустые даты означают отсутствие зафиксированного посещения. +Внешняя регистрационная форма автоматически пропускает онбординг, поэтому его +завершение не используется как свидетельство посещения. + +Старые `onboarded_participants`, `participants_without_onboarding` и endpoint +`participants-without-onboarding/` сохранены с прежней семантикой для совместимости +при последовательной выкладке. Новый интерфейс их не использует и не подставляет +показатели онбординга при отсутствии новой метрики посещения. + Существующее API-поле `projects_not_submitted` сохранено для совместимости, но его строка в «Требует внимания» убрана по требованию не дублировать показатель, который получается как «Создано − Сдано». @@ -114,7 +132,7 @@ Endpoint доступен менеджерам указанной програм пользователем. - `unique_participants` и `summary.participants.total` — уникальные ненулевые `PartnerProgramUserProfile.user_id`. -- `onboarded_participants` — уникальные ненулевые пользователи, зарегистрированные +- `onboarded_participants` (устаревшее поле для совместимости) — уникальные ненулевые пользователи, зарегистрированные в этой программе, у которых `User.onboarding_stage IS NULL`: завершён стартовый сценарий платформы. Подтверждение почты (`is_active`) и произвольный запрос к API (`last_activity`) не заменяют этот критерий. Онбординг позволяет пропускать шаги, @@ -123,7 +141,7 @@ Endpoint доступен менеджерам указанной програм до или после регистрации в программе, по этому полю нельзя. - Этапы участников показывают текущее состояние одной группы зарегистрированных пользователей, а не последовательную событийную конверсию: `with_team` может - превышать `onboarded_participants`, если команда появилась раньше. Значения + превышать `visited_participants`, если команда была создана без записанного посещения. Значения возвращаются без сортировки, ограничения или искусственного уменьшения. - Участник считается состоящим в команде, если он является руководителем либо `Collaborator` проекта, связанного с программой через diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 6f93d633..e54e21da 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) + visited_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) @@ -136,6 +137,7 @@ class ProjectsNotSubmittedSerializer(AnalyticsTotalSerializer): class ProgramAttentionSerializer(serializers.Serializer): + participants_without_visit = serializers.IntegerField(min_value=0) participants_without_onboarding = serializers.IntegerField(min_value=0) participants_without_team = serializers.IntegerField(min_value=0) projects_awaiting_evaluation = serializers.IntegerField(min_value=0) diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 34fe0e8a..8478c789 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -72,6 +72,31 @@ def participants_without_team_rows(program_id): ) +def _visited_participant_filter(): + """Зарегистрированный пользователь с активностью или входом на платформу.""" + return Q(user_id__isnull=False) & ( + Q(user__last_activity__isnull=False) | Q(user__last_login__isnull=False) + ) + + +def _without_visit_filter(): + """Оба свидетельства посещения отсутствуют; онбординг не учитывается.""" + return Q( + user_id__isnull=False, + user__last_activity__isnull=True, + user__last_login__isnull=True, + ) + + +def participants_without_visit_rows(program_id): + """Тот же признак отсутствия посещения, что и в счётчике внимания.""" + return _unique_participant_rows( + PartnerProgramUserProfile.objects.filter(partner_program_id=program_id).filter( + _without_visit_filter() + ) + ) + + def _without_onboarding_filter(): """Незавершённый онбординг у существующего пользователя программы.""" return Q(user_id__isnull=False, user__onboarding_stage__isnull=False) @@ -113,6 +138,10 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]: filter=participant_filter, distinct=True, ), + visited_participants=Count( + "user_id", filter=_visited_participant_filter(), distinct=True + ), + without_visit=Count("user_id", filter=_without_visit_filter(), distinct=True), onboarded_participants=Count( "user_id", filter=participant_filter & Q(user__onboarding_stage__isnull=True), @@ -381,6 +410,7 @@ def build_program_manager_analytics(program) -> dict: "participant_funnel": { "registrations": participants["registrations"], "unique_participants": participants["unique_participants"], + "visited_participants": participants["visited_participants"], "onboarded_participants": participants["onboarded_participants"], "with_team": participants["with_team"], "project_creators": participants["project_creators"], @@ -408,6 +438,7 @@ def build_program_manager_analytics(program) -> dict: }, }, "attention": { + "participants_without_visit": participants["without_visit"], "participants_without_onboarding": participants["without_onboarding"], "participants_without_team": participants["without_team"], "projects_awaiting_evaluation": projects_awaiting_evaluation, diff --git a/partner_programs/tests/test_attention_analytics_api.py b/partner_programs/tests/test_attention_analytics_api.py index 952b6163..78c3cf7c 100644 --- a/partner_programs/tests/test_attention_analytics_api.py +++ b/partner_programs/tests/test_attention_analytics_api.py @@ -26,6 +26,7 @@ NOW = datetime(2026, 9, 5, 12, tzinfo=datetime_timezone.utc) PARTICIPANTS = "manager-overview-participants-without-team" ONBOARDING = "manager-overview-participants-without-onboarding" +VISITS = "manager-overview-participants-without-visit" PROJECTS = "manager-overview-projects-awaiting-evaluation" @@ -115,7 +116,7 @@ def assert_matches_overview(self, endpoint, metric): class AttentionAnalyticsAccessAndPaginationTests(AttentionAnalyticsTestCase): def test_manager_has_access_to_both_lists(self): - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): self.assertEqual(self.get(endpoint)["results"], []) @@ -123,31 +124,31 @@ def test_staff_and_superuser_have_access_to_both_lists(self): for permission in ("is_staff", "is_superuser"): user = create_user(prefix=f"attention-{permission}", **{permission: True}) self.client.force_authenticate(user) - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(permission=permission, endpoint=endpoint): self.get(endpoint) def test_anonymous_get_is_unauthorized(self): self.client.force_authenticate(None) - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): self.assertEqual(self.client.get(self.url(endpoint)).status_code, 401) def test_participant_get_is_forbidden(self): participant = self.participant().user self.client.force_authenticate(participant) - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): self.assertEqual(self.client.get(self.url(endpoint)).status_code, 403) def test_program_expert_get_is_forbidden(self): self.client.force_authenticate(self.expert) - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): self.assertEqual(self.client.get(self.url(endpoint)).status_code, 403) def test_manager_of_another_program_cannot_access_or_search_lists(self): - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): response = self.client.get( self.url(endpoint, self.other_program), {"search": "Program"} @@ -155,7 +156,7 @@ def test_manager_of_another_program_cannot_access_or_search_lists(self): self.assertEqual(response.status_code, 403) def test_missing_program_is_not_found(self): - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): url = reverse("partner_programs:" + endpoint, kwargs={"pk": 99999999}) self.assertEqual(self.client.get(url).status_code, 404) @@ -163,7 +164,7 @@ def test_missing_program_is_not_found(self): def test_modifying_methods_are_not_allowed_and_leave_data_unchanged(self): registration = self.participant() link = self.work() - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): for method in ("post", "put", "patch", "delete"): with self.subTest(endpoint=endpoint, method=method): response = getattr(self.client, method)( @@ -177,7 +178,7 @@ def test_modifying_methods_are_not_allowed_and_leave_data_unchanged(self): self.assertTrue(link.submitted) def test_invalid_pagination_is_rejected_on_both_endpoints(self): - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): for params in ( {"limit": "invalid"}, {"limit": "1.5"}, @@ -196,7 +197,7 @@ def test_invalid_pagination_is_rejected_on_both_endpoints(self): ) def test_empty_page_has_standard_pagination_envelope(self): - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): data = self.get(endpoint) expected = {"count", "next", "previous", "results"} @@ -210,7 +211,7 @@ def test_empty_page_has_standard_pagination_envelope(self): def test_offset_beyond_total_returns_empty_page_without_losing_count(self): self.participant() self.work() - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): with self.subTest(endpoint=endpoint): data = self.get(endpoint, offset=100) self.assertEqual(data["count"], 1) @@ -219,7 +220,7 @@ def test_offset_beyond_total_returns_empty_page_without_losing_count(self): def test_empty_search_matches_absent_search(self): self.participant() self.work() - for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, VISITS, PROJECTS): for query in ("", " ", "\t"): with self.subTest(endpoint=endpoint, query=query): self.assertEqual( @@ -228,6 +229,102 @@ def test_empty_search_matches_absent_search(self): ) +class ParticipantsWithoutVisitTests(AttentionAnalyticsTestCase): + def test_only_both_missing_timestamps_are_included(self): + missing = self.participant(onboarding_stage=None, is_active=True) + also_missing = self.participant(onboarding_stage=0, is_active=False) + self.participant(last_activity=NOW) + self.participant(last_login=NOW) + self.participant(last_activity=NOW, last_login=NOW) + page = self.get(VISITS) + self.assertEqual(page["count"], 2) + self.assertEqual( + {row["user_id"] for row in page["results"]}, + {missing.user_id, also_missing.user_id}, + ) + self.assert_matches_overview(VISITS, "participants_without_visit") + + def test_either_recorded_timestamp_removes_participant_from_attention(self): + for field in ("last_activity", "last_login"): + with self.subTest(field=field): + registration = self.participant(onboarding_stage=0) + self.assertEqual(self.get(VISITS)["count"], 1) + setattr(registration.user, field, NOW) + registration.user.save(update_fields=[field]) + self.assertEqual(self.get(VISITS)["count"], 0) + overview = self.get("manager-overview") + self.assertEqual(overview["attention"]["participants_without_visit"], 0) + self.assertEqual( + overview["participant_funnel"]["visited_participants"], + overview["participant_funnel"]["unique_participants"], + ) + + def test_real_login_updates_both_funnel_and_attention_without_onboarding(self): + registration = self.participant(onboarding_stage=0, is_active=True) + self.assertEqual(self.get(VISITS)["count"], 1) + login = APIClient().post( + reverse("token_obtain_pair"), + {"email": registration.user.email, "password": "pass"}, + format="json", + ) + self.assertEqual(login.status_code, 200) + registration.user.refresh_from_db() + self.assertIsNone(registration.user.last_activity) + self.assertIsNotNone(registration.user.last_login) + self.assertEqual(registration.user.onboarding_stage, 0) + self.assertEqual(self.get(VISITS)["count"], 0) + overview = self.get("manager-overview") + self.assertEqual(overview["participant_funnel"]["visited_participants"], 1) + self.assertEqual(overview["attention"]["participants_without_visit"], 0) + + def test_activity_before_program_registration_still_counts_as_a_visit(self): + self.participant(last_activity=NOW - timedelta(days=100)) + self.participant(last_login=NOW - timedelta(days=100)) + self.assertEqual(self.get(VISITS)["count"], 0) + self.assertEqual( + self.get("manager-overview")["participant_funnel"]["visited_participants"], + 2, + ) + + def test_team_membership_does_not_invent_a_visit(self): + registration = self.participant(onboarding_stage=None) + self.work(leader=registration.user) + self.assertEqual(self.get(VISITS)["count"], 1) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_other_program_unregistered_and_deleted_users_are_excluded(self): + included = self.participant() + self.participant(program=self.other_program) + create_user() + deleted = self.participant() + deleted.user.delete() + page = self.get(VISITS) + self.assertEqual(page["count"], 1) + self.assertEqual(page["results"][0]["user_id"], included.user_id) + self.assert_matches_overview(VISITS, "participants_without_visit") + + def test_search_and_pagination_exclude_visited_users_before_counting(self): + oldest = self.participant(hours=20, first_name="Alice") + newest = self.participant(hours=1, first_name="Alice") + self.participant(hours=30, first_name="Bob") + self.participant(hours=40, first_name="Alice", last_activity=NOW) + self.participant(hours=50, first_name="Alice", last_login=NOW) + first = self.get(VISITS, search=" alice ", limit=1) + second = self.get(VISITS, search="alice", limit=1, offset=1) + self.assertEqual(first["count"], 2) + self.assertEqual(first["results"][0]["user_id"], oldest.user_id) + self.assertEqual(second["results"][0]["user_id"], newest.user_id) + self.assertIsNotNone(first["next"]) + self.assertIsNone(second["next"]) + + def test_list_returns_only_safe_public_participant_fields(self): + self.participant(first_name="", last_name="") + row = self.get(VISITS)["results"][0] + self.assertEqual( + set(row), {"user_id", "full_name", "avatar", "city", "registered_at"} + ) + + class ParticipantsWithoutOnboardingTests(AttentionAnalyticsTestCase): def test_stages_zero_and_in_progress_are_included_independent_of_email_status(self): first = self.participant(onboarding_stage=0, is_active=False) diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index 5364a52f..fd7c819b 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, + "visited_participants": 0, "onboarded_participants": 0, "with_team": 2, "project_creators": 1, @@ -156,6 +157,40 @@ def test_summary_participant_funnel_and_regions_use_real_relations(self): ) self.assertEqual(response.data["attention"]["participants_without_team"], 1) + def test_visits_use_activity_or_login_and_ignore_onboarding_and_email_status(self): + timestamp = timezone.now() - timedelta(days=100) + users = ( + create_user(last_activity=timestamp, onboarding_stage=0, is_active=False), + create_user(last_login=timestamp, onboarding_stage=2), + create_user(last_activity=timestamp, last_login=timestamp), + create_user(onboarding_stage=None, is_active=True), + ) + for user in users: + create_program_member(self.program, user=user) + create_program_member( + create_partner_program(), user=create_user(last_activity=timestamp) + ) + create_program_project( + self.program, + project=create_project(leader=create_user(last_login=timestamp)), + ) + removed = create_program_member( + self.program, user=create_user(last_activity=timestamp) + ) + removed.user.delete() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + funnel = response.data["participant_funnel"] + self.assertEqual(funnel["unique_participants"], 4) + self.assertEqual(funnel["visited_participants"], 3) + self.assertEqual(response.data["attention"]["participants_without_visit"], 1) + self.assertEqual(funnel["onboarded_participants"], 1) + self.assertEqual( + funnel["unique_participants"], + funnel["visited_participants"] + + response.data["attention"]["participants_without_visit"], + ) + 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) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 769812d4..840618ea 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -13,6 +13,7 @@ ProgramManagerAssignmentScoresAPIView, ProgramManagerParticipantsWithoutTeamAPIView, ProgramManagerParticipantsWithoutOnboardingAPIView, + ProgramManagerParticipantsWithoutVisitAPIView, ProgramManagerProjectsAwaitingEvaluationAPIView, ProgramManagerProjectsNotSubmittedAPIView, PartnerProgramProjectApplyView, @@ -44,6 +45,11 @@ ProgramManagerAnalyticsAPIView.as_view(), name="manager-overview", ), + path( + "/manager-overview/participants-without-visit/", + ProgramManagerParticipantsWithoutVisitAPIView.as_view(), + name="manager-overview-participants-without-visit", + ), path( "/manager-overview/participants-without-onboarding/", ProgramManagerParticipantsWithoutOnboardingAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 873f56f1..57170d40 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -77,6 +77,7 @@ ) from partner_programs.services.analytics import ( participants_without_onboarding_rows, + participants_without_visit_rows, participants_without_team_rows, projects_awaiting_evaluation_rows, projects_not_submitted_rows, @@ -503,6 +504,14 @@ def get_queryset(self, program, search): return queryset.order_by(F("registered_at").asc(nulls_last=True), "user_id") +class ProgramManagerParticipantsWithoutVisitAPIView( + ProgramManagerParticipantsWithoutTeamAPIView +): + """Участники без зафиксированной активности и входа на платформу.""" + + participant_rows = staticmethod(participants_without_visit_rows) + + class ProgramManagerParticipantsWithoutOnboardingAPIView( ProgramManagerParticipantsWithoutTeamAPIView ):