From fba84ccbc7e9aadc957f85955b1c7d74a684a48a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 12 Oct 2026 00:12:28 +0300 Subject: [PATCH 1/2] feat(analytics): surface participants with unfinished onboarding --- docs/program-manager-analytics-api.md | 28 ++++++ partner_programs/serializers/analytics.py | 1 + partner_programs/services/analytics.py | 31 ++++++- .../tests/test_attention_analytics_api.py | 86 ++++++++++++++++--- .../tests/test_manager_analytics_api.py | 2 + partner_programs/urls.py | 6 ++ partner_programs/views.py | 12 ++- 7 files changed, 150 insertions(+), 16 deletions(-) diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 8b00e653..6f3a4867 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -65,6 +65,7 @@ Endpoint доступен менеджерам указанной програм } }, "attention": { + "participants_without_onboarding": 1, "participants_without_team": 1, "projects_awaiting_evaluation": 1, "delayed_experts": {"total": 0, "items": []} @@ -81,6 +82,33 @@ Endpoint доступен менеджерам указанной програм ## Семантика метрик +### Требует внимания: условия срабатывания + +Все счётчики относятся к текущей программе и обновляются при запросе аналитики. +Пункт доступен для открытия списка при значении больше нуля; нулевые строки +отключены. Если все значения равны нулю, интерфейс пишет «Ничего не требует внимания». + +| Пункт интерфейса | Условие | +| --- | --- | +| Участники не завершили онбординг | Есть уникальные зарегистрированные пользователи с `onboarding_stage IS NOT NULL`, включая этап 0. Завершённый онбординг — `NULL`. Подтверждение почты и членство в команде не заменяют онбординг; временного порога нет. | +| Участники без команды | Зарегистрированный пользователь не является ни руководителем, ни `Collaborator` проекта, связанного с этой программой. Команда в другой программе не исключает пользователя; временного порога нет. | +| Работы ожидают оценивания | Только сданные работы. В открытом режиме нет ни одной оценки по критериям программы. В распределённом режиме нет назначений либо хотя бы одно назначение ещё не завершено, включая частично оценённые работы. Временного порога нет. | +| Эксперты задерживают оценивание | Только распределённое оценивание: у эксперта минимум два незавершённых назначения ждут 24 часа либо хотя бы одно — 48 часов. Порог 48 часов имеет критическую серьёзность. Ожидание начинается с более поздней даты: сдача проекта или создание назначения; неизвестная дата сдачи не даёт отсчёта. Несданные работы и завершённые назначения не учитываются в задержке. | + +Новый счётчик `attention.participants_without_onboarding` использует тот же +предикат, что и список +`GET /programs/{id}/manager-overview/participants-without-onboarding/`. +Список поддерживает `search`, `limit` (1–100, по умолчанию 25), `offset`; +возвращает стандартные `count`, `next`, `previous`, `results` с публичными полями +участника. Права, поиск, порядок и пагинация совпадают со списком участников +без команды. Удалённые пользователи и регистрации других программ исключены. +Равенство: `unique_participants = onboarded_participants + participants_without_onboarding`. +Новая миграция БД не требуется. + +Существующее API-поле `projects_not_submitted` сохранено для совместимости, +но его строка в «Требует внимания» убрана по требованию не дублировать +показатель, который получается как «Создано − Сдано». + - `registrations` — количество регистрационных записей `PartnerProgramUserProfile`, включая сохранённые записи с удалённым пользователем. diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 8b69c780..342a4e7a 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -136,6 +136,7 @@ class ProjectsNotSubmittedSerializer(AnalyticsTotalSerializer): class ProgramAttentionSerializer(serializers.Serializer): + participants_without_onboarding = serializers.IntegerField(min_value=0) participants_without_team = serializers.IntegerField(min_value=0) projects_awaiting_evaluation = serializers.IntegerField(min_value=0) projects_not_submitted = ProjectsNotSubmittedSerializer() diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 5159624a..f908025e 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -67,10 +67,29 @@ def participants_without_team_rows(program_id): Группировка сохраняет одну строку на user_id даже при исторических дублях. Удалённые пользователи исключены; поиск и пагинация остаются на уровне SQL. """ + return _unique_participant_rows( + _participant_profiles(program_id).filter(_without_team_filter()) + ) + + +def _without_onboarding_filter(): + """Незавершённый онбординг у существующего пользователя программы.""" + return Q(user_id__isnull=False, user__onboarding_stage__isnull=False) + + +def participants_without_onboarding_rows(program_id): + """Тот же признак незавершённого онбординга, что и в счётчике внимания.""" + return _unique_participant_rows( + PartnerProgramUserProfile.objects.filter(partner_program_id=program_id).filter( + _without_onboarding_filter() + ) + ) + + +def _unique_participant_rows(profiles): + """Публичные поля уникального пользователя и его первая регистрация в программе.""" return ( - _participant_profiles(program_id) - .filter(_without_team_filter()) - .order_by() + profiles.order_by() .values( "user_id", "user__first_name", @@ -99,6 +118,11 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]: filter=participant_filter & Q(user__onboarding_stage__isnull=True), distinct=True, ), + without_onboarding=Count( + "user_id", + filter=_without_onboarding_filter(), + distinct=True, + ), with_team=Count( "user_id", filter=participant_filter & team_filter, @@ -383,6 +407,7 @@ def build_program_manager_analytics(program) -> dict: }, }, "attention": { + "participants_without_onboarding": participants["without_onboarding"], "participants_without_team": participants["without_team"], "projects_awaiting_evaluation": projects_awaiting_evaluation, "projects_not_submitted": { diff --git a/partner_programs/tests/test_attention_analytics_api.py b/partner_programs/tests/test_attention_analytics_api.py index 3d4e43f7..952b6163 100644 --- a/partner_programs/tests/test_attention_analytics_api.py +++ b/partner_programs/tests/test_attention_analytics_api.py @@ -25,6 +25,7 @@ NOW = datetime(2026, 9, 5, 12, tzinfo=datetime_timezone.utc) PARTICIPANTS = "manager-overview-participants-without-team" +ONBOARDING = "manager-overview-participants-without-onboarding" PROJECTS = "manager-overview-projects-awaiting-evaluation" @@ -114,7 +115,7 @@ def assert_matches_overview(self, endpoint, metric): class AttentionAnalyticsAccessAndPaginationTests(AttentionAnalyticsTestCase): def test_manager_has_access_to_both_lists(self): - for endpoint in (PARTICIPANTS, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): with self.subTest(endpoint=endpoint): self.assertEqual(self.get(endpoint)["results"], []) @@ -122,31 +123,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, 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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, 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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, 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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, 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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): with self.subTest(endpoint=endpoint): response = self.client.get( self.url(endpoint, self.other_program), {"search": "Program"} @@ -154,7 +155,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): with self.subTest(endpoint=endpoint): url = reverse("partner_programs:" + endpoint, kwargs={"pk": 99999999}) self.assertEqual(self.client.get(url).status_code, 404) @@ -162,7 +163,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): for method in ("post", "put", "patch", "delete"): with self.subTest(endpoint=endpoint, method=method): response = getattr(self.client, method)( @@ -176,7 +177,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): for params in ( {"limit": "invalid"}, {"limit": "1.5"}, @@ -195,7 +196,7 @@ def test_invalid_pagination_is_rejected_on_both_endpoints(self): ) def test_empty_page_has_standard_pagination_envelope(self): - for endpoint in (PARTICIPANTS, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): with self.subTest(endpoint=endpoint): data = self.get(endpoint) expected = {"count", "next", "previous", "results"} @@ -209,7 +210,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): with self.subTest(endpoint=endpoint): data = self.get(endpoint, offset=100) self.assertEqual(data["count"], 1) @@ -218,7 +219,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, PROJECTS): + for endpoint in (PARTICIPANTS, ONBOARDING, PROJECTS): for query in ("", " ", "\t"): with self.subTest(endpoint=endpoint, query=query): self.assertEqual( @@ -227,6 +228,67 @@ def test_empty_search_matches_absent_search(self): ) +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) + second = self.participant(onboarding_stage=2, is_active=True) + self.participant(onboarding_stage=None) + data = self.get(ONBOARDING) + self.assertEqual(data["count"], 2) + self.assertEqual( + {row["user_id"] for row in data["results"]}, + {first.user_id, second.user_id}, + ) + self.assert_matches_overview(ONBOARDING, "participants_without_onboarding") + + def test_team_membership_does_not_complete_onboarding(self): + registration = self.participant(onboarding_stage=1) + self.work(leader=registration.user) + self.assertEqual(self.get(ONBOARDING)["count"], 1) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_completion_removes_participant_from_list_and_attention(self): + registration = self.participant(onboarding_stage=2) + self.assertEqual(self.get(ONBOARDING)["count"], 1) + registration.user.onboarding_stage = None + registration.user.save(update_fields=["onboarding_stage"]) + self.assertEqual(self.get(ONBOARDING)["count"], 0) + overview = self.get("manager-overview") + self.assertEqual(overview["attention"]["participants_without_onboarding"], 0) + self.assertEqual(overview["participant_funnel"]["onboarded_participants"], 1) + + def test_other_program_unregistered_and_deleted_users_are_excluded(self): + included = self.participant(onboarding_stage=0) + self.participant(program=self.other_program, onboarding_stage=0) + create_user(onboarding_stage=0) + deleted = self.participant(onboarding_stage=2) + deleted.user.delete() + data = self.get(ONBOARDING) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["user_id"], included.user_id) + self.assert_matches_overview(ONBOARDING, "participants_without_onboarding") + + def test_search_filters_full_set_before_pagination_and_preserves_order(self): + oldest = self.participant(hours=20, first_name="Alice", onboarding_stage=0) + newest = self.participant(hours=1, first_name="Alice", onboarding_stage=2) + self.participant(hours=30, first_name="Bob", onboarding_stage=0) + self.participant(hours=40, first_name="Alice", onboarding_stage=None) + first = self.get(ONBOARDING, search=" alice ", limit=1) + second = self.get(ONBOARDING, 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(onboarding_stage=0, first_name="", last_name="") + row = self.get(ONBOARDING)["results"][0] + self.assertEqual( + set(row), {"user_id", "full_name", "avatar", "city", "registered_at"} + ) + + class ParticipantsWithoutTeamTests(AttentionAnalyticsTestCase): def test_registered_participant_without_team_is_included(self): registration = self.participant() diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index 547acd2e..5364a52f 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -184,12 +184,14 @@ def test_onboarding_counts_only_registered_users_with_completed_onboarding(self) self.assertEqual(funnel["registrations"], 4) self.assertEqual(funnel["unique_participants"], 3) self.assertEqual(funnel["onboarded_participants"], 1) + self.assertEqual(response.data["attention"]["participants_without_onboarding"], 2) 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) + self.assertEqual(response.data["attention"]["participants_without_onboarding"], 1) def test_participant_regions_use_only_program_participants_and_preserve_legacy_values( self, diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 0b536c8e..769812d4 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -12,6 +12,7 @@ ProgramManagerAssignmentsAPIView, ProgramManagerAssignmentScoresAPIView, ProgramManagerParticipantsWithoutTeamAPIView, + ProgramManagerParticipantsWithoutOnboardingAPIView, ProgramManagerProjectsAwaitingEvaluationAPIView, ProgramManagerProjectsNotSubmittedAPIView, PartnerProgramProjectApplyView, @@ -43,6 +44,11 @@ ProgramManagerAnalyticsAPIView.as_view(), name="manager-overview", ), + path( + "/manager-overview/participants-without-onboarding/", + ProgramManagerParticipantsWithoutOnboardingAPIView.as_view(), + name="manager-overview-participants-without-onboarding", + ), path( "/manager-overview/participants-without-team/", ProgramManagerParticipantsWithoutTeamAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 7fc2b9b2..873f56f1 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -76,6 +76,7 @@ ProgramNotSubmittedProjectSerializer, ) from partner_programs.services.analytics import ( + participants_without_onboarding_rows, participants_without_team_rows, projects_awaiting_evaluation_rows, projects_not_submitted_rows, @@ -490,10 +491,11 @@ class ProgramManagerParticipantsWithoutTeamAPIView(ProgramManagerAttentionListAP serializer_class = ProgramAttentionParticipantSerializer include_mode = False + participant_rows = staticmethod(participants_without_team_rows) def get_queryset(self, program, search): """Ищет по имени до пагинации; старые регистрации первыми с user_id tie-breaker.""" - queryset = participants_without_team_rows(program.pk) + queryset = self.participant_rows(program.pk) if search: queryset = queryset.annotate( search_name=Concat("user__first_name", Value(" "), "user__last_name") @@ -501,6 +503,14 @@ def get_queryset(self, program, search): return queryset.order_by(F("registered_at").asc(nulls_last=True), "user_id") +class ProgramManagerParticipantsWithoutOnboardingAPIView( + ProgramManagerParticipantsWithoutTeamAPIView +): + """Зарегистрированные участники с незавершённым онбордингом платформы.""" + + participant_rows = staticmethod(participants_without_onboarding_rows) + + class ProgramManagerProjectsAwaitingEvaluationAPIView(ProgramManagerAttentionListAPIView): """Сданные работы из того же предиката, что и projects_awaiting_evaluation.""" From c1908b08fdb3e4975ed40216023ae4c682665e38 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 12 Oct 2026 00:30:43 +0300 Subject: [PATCH 2/2] fix(analytics): flag unfinished expert evaluations five days before deadline --- docs/program-manager-analytics-api.md | 41 ++++-- partner_programs/serializers/analytics.py | 4 +- partner_programs/services/analytics.py | 9 +- .../services/assignment_analytics.py | 31 +++-- .../tests/test_assignment_analytics_api.py | 127 ++++++++++++++---- 5 files changed, 153 insertions(+), 59 deletions(-) diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 6f3a4867..1b947c2f 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -93,7 +93,7 @@ Endpoint доступен менеджерам указанной програм | Участники не завершили онбординг | Есть уникальные зарегистрированные пользователи с `onboarding_stage IS NOT NULL`, включая этап 0. Завершённый онбординг — `NULL`. Подтверждение почты и членство в команде не заменяют онбординг; временного порога нет. | | Участники без команды | Зарегистрированный пользователь не является ни руководителем, ни `Collaborator` проекта, связанного с этой программой. Команда в другой программе не исключает пользователя; временного порога нет. | | Работы ожидают оценивания | Только сданные работы. В открытом режиме нет ни одной оценки по критериям программы. В распределённом режиме нет назначений либо хотя бы одно назначение ещё не завершено, включая частично оценённые работы. Временного порога нет. | -| Эксперты задерживают оценивание | Только распределённое оценивание: у эксперта минимум два незавершённых назначения ждут 24 часа либо хотя бы одно — 48 часов. Порог 48 часов имеет критическую серьёзность. Ожидание начинается с более поздней даты: сдача проекта или создание назначения; неизвестная дата сдачи не даёт отсчёта. Несданные работы и завершённые назначения не учитываются в задержке. | +| Эксперты задерживают оценивание | Только распределённое оценивание: задана `datetime_evaluation_ends`, наступил момент за 5 суток до неё (`now >= datetime_evaluation_ends - 5 days`), у эксперта есть хотя бы одно назначение со статусом `pending` или `in_progress` на сданную работу. До дедлайна — `warning`, начиная с дедлайна — `critical`; сигнал остаётся до завершения оценок. Время ожидания 24/48 часов не влияет на появление. Несданные работы и завершённые назначения не создают сигнал. | Новый счётчик `attention.participants_without_onboarding` использует тот же предикат, что и список @@ -312,9 +312,10 @@ overview, scoring, team/invite rules, фильтры, сохранение field Для `not_ready`: `project_submitted_at`, `waiting_since`, `waiting_seconds` — `null`. Для `completed` оба поля ожидания — `null`, дата сдачи сохранена. Если у legacy-сданного проекта `datetime_submitted=null`, статус рассчитывается -обычно, но ожидание остаётся `null`: достоверного начала SLA нет. Такая запись -не объявляется просроченной. Дата создания проекта никогда не подставляется. -Будущая дата даёт 0 секунд ожидания и не создаёт просрочку. +обычно, но ожидание остаётся `null`: достоверного начала ожидания нет. Это не +мешает сигналу по дедлайну оценивания. Дата создания проекта никогда не +подставляется. Будущая дата даёт 0 секунд ожидания; длительность ожидания не +определяет появление пункта «Эксперты задерживают оценивание». ## Оценки назначения @@ -374,16 +375,25 @@ overview, scoring, team/invite rules, фильтры, сохранение field Существующие `participants_without_team` и `projects_awaiting_evaluation` сохраняются. В open-режиме всегда `{"total": 0, "items": []}`. -SLA учитывает только сданные, незавершённые назначения с известным наступившим -`waiting_since`: +Сигнал использует только отдельную дату окончания оценки программы +`datetime_evaluation_ends`. Если она не задана, список пуст: даты окончания +программы, регистрации или подачи проектов не подставляются. -- `warning`: минимум 2 назначения ждут каждое >= 24 часов; -- `critical`: хотя бы 1 назначение ждёт >= 48 часов (имеет приоритет). +- До `datetime_evaluation_ends - timedelta(days=5)` список пуст, независимо + от времени ожидания и числа назначений. +- Начиная ровно за 5 суток до дедлайна эксперт попадает в список, если у него + есть хотя бы одно назначение со статусом `pending` или `in_progress`. +- `warning`: дедлайн ещё не наступил; `critical`: `now >= datetime_evaluation_ends`. +- После дедлайна сигнал сохраняется до завершения всех оценок сданных работ. -Один проект, ожидающий 25 часов, не даёт предупреждение. Выполненные, -несданные, будущие и назначения другой программы не создают просрочку. +Свежие назначения и legacy-сданные работы без даты сдачи учитываются: +известная длительность ожидания не требуется. Выполненные, несданные и +назначения другой программы не создают сигнал. `assignments_total`, `completed`, `pending` включают все реальные назначения эксперта текущей программы, включая несданные в `pending`. +`overdue_24h` и `overdue_48h` сохранены для совместимости как сведения о +длительности ожидания; они не определяют включение в список или серьёзность. +Это пункт аналитики; уведомления экспертам не отправляются. ```json { @@ -409,9 +419,11 @@ SLA учитывает только сданные, незавершённые } ``` -Сортировка: critical перед warning, затем большее время ожидания, затем -`expert_id` по возрастанию. Старейшее ожидание берётся среди незавершённых -сданных назначений с известной датой, а не по дате создания проекта. +Сортировка: известное время ожидания перед неизвестным, затем большее время +ожидания, затем `expert_id` по возрастанию. Старейшее ожидание берётся среди +незавершённых сданных назначений с известной датой, а не по дате создания +проекта. При неизвестной дате оба поля `oldest_waiting_*` равны `null`; +нулевое ожидание сохраняется как 0. Серьёзность одинакова для дедлайна программы. ## Производительность и границы @@ -419,7 +431,8 @@ SLA учитывает только сданные, незавершённые SQL SELECT: связанные таблицы через JOIN, число критериев и DISTINCT-оценок — через Subquery/Count; связь проекта с программой также через Subquery. Нет запросов из сериализаторов и отдельных SQL-запросов в цикле назначений. -Сводка использует тот же список для SLA и счётчиков назначений. Статусы работ +Сводка использует тот же список и один момент `now` для сигнала по дедлайну +и счётчиков назначений. Статусы работ вычисляет общий SQL-queryset сводки и списка ожидающих работ; проверка завершения назначения общая с assignments. Внешние поля сводки и assignments не меняются. Score drilldown добавляет два фиксированных запроса (критерии и оценки пары). diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 342a4e7a..6f93d633 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -120,8 +120,8 @@ class DelayedExpertSerializer(AssignmentExpertSerializer): pending = serializers.IntegerField(min_value=0) overdue_24h = serializers.IntegerField(min_value=0) overdue_48h = serializers.IntegerField(min_value=0) - oldest_waiting_since = serializers.DateTimeField() - oldest_waiting_seconds = serializers.IntegerField(min_value=0) + oldest_waiting_since = serializers.DateTimeField(allow_null=True) + oldest_waiting_seconds = serializers.IntegerField(min_value=0, allow_null=True) severity = serializers.ChoiceField(choices=("critical", "warning")) diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index f908025e..34fe0e8a 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -355,10 +355,11 @@ def _get_activity(program_id: int) -> list[dict]: def build_program_manager_analytics(program) -> dict: program_id = program.id + now = timezone.now() participants = _get_participant_metrics(program_id) regions = _get_regions(program_id) participant_regions = _get_participant_regions(program_id) - assignment_items = build_assignments(program_id) + assignment_items = build_assignments(program_id, now=now) assignments = _get_assignment_metrics(assignment_items) solutions = _get_solution_metrics(program) @@ -415,7 +416,11 @@ def build_program_manager_analytics(program) -> dict: "total": solutions["not_submitted"] if program.is_competitive else 0, }, "delayed_experts": ( - build_delayed_experts(assignment_items) + build_delayed_experts( + assignment_items, + evaluation_ends=program.datetime_evaluation_ends, + now=now, + ) if program.is_distributed_evaluation else {"total": 0, "items": []} ), diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py index 2b862b19..7dd429ed 100644 --- a/partner_programs/services/assignment_analytics.py +++ b/partner_programs/services/assignment_analytics.py @@ -1,5 +1,7 @@ """Read-only analytics of real expert assignments; no scoring/lifecycle writes.""" +from datetime import timedelta + from django.db.models import BooleanField, Case, Count, F, OuterRef, Subquery, Value, When from django.db.models.functions import Coalesce from django.utils import timezone @@ -104,7 +106,7 @@ def build_assignment(row, *, now): submitted_at = row["project_submitted_at"] if row["project_submitted"] else None waiting_since = None waiting_seconds = None - # Missing historical submission timestamps cannot establish an SLA start. + # Missing historical submission timestamps cannot establish waiting duration. if status not in ("not_ready", "completed") and submitted_at is not None: waiting_since = max(submitted_at, row["datetime_created"]) waiting_seconds = max(0, int((now - waiting_since).total_seconds())) @@ -131,8 +133,8 @@ def build_assignment(row, *, now): } -def build_assignments(program_id): - now = timezone.now() +def build_assignments(program_id, *, now=None): + now = now if now is not None else timezone.now() return [build_assignment(row, now=now) for row in assignment_rows(program_id)] @@ -160,8 +162,14 @@ def build_assignment_scores(program_id, assignment): ] -def build_delayed_experts(assignments): +def build_delayed_experts(assignments, *, evaluation_ends, now=None): + """Flag unfinished submitted work from five days before evaluation closes.""" + now = now if now is not None else timezone.now() + if evaluation_ends is None or now < evaluation_ends - timedelta(days=5): + return {"total": 0, "items": []} + experts = {} + unfinished_experts = set() for assignment in assignments: expert = assignment["expert"] item = experts.setdefault( @@ -179,12 +187,14 @@ def build_delayed_experts(assignments): ) item["assignments_total"] += 1 item["completed" if assignment["status"] == "completed" else "pending"] += 1 + if assignment["status"] in ("pending", "in_progress"): + unfinished_experts.add(expert["expert_id"]) seconds = assignment["waiting_seconds"] if seconds is None: continue item["overdue_24h"] += int(seconds >= 24 * 3600) item["overdue_48h"] += int(seconds >= 48 * 3600) - if seconds > 0 and ( + if ( item["oldest_waiting_seconds"] is None or seconds > item["oldest_waiting_seconds"] ): @@ -193,17 +203,14 @@ def build_delayed_experts(assignments): items = [] for item in experts.values(): - if item["overdue_48h"] >= 1: - item["severity"] = "critical" - elif item["overdue_24h"] >= 2: - item["severity"] = "warning" - else: + if item["expert_id"] not in unfinished_experts: continue + item["severity"] = "critical" if now >= evaluation_ends else "warning" items.append(item) items.sort( key=lambda item: ( - item["severity"] != "critical", - -item["oldest_waiting_seconds"], + item["oldest_waiting_seconds"] is None, + -(item["oldest_waiting_seconds"] or 0), item["expert_id"], ) ) diff --git a/partner_programs/tests/test_assignment_analytics_api.py b/partner_programs/tests/test_assignment_analytics_api.py index 2e4f0eed..b4fdd2ef 100644 --- a/partner_programs/tests/test_assignment_analytics_api.py +++ b/partner_programs/tests/test_assignment_analytics_api.py @@ -26,7 +26,9 @@ class AssignmentAnalyticsTests(TestCase): def setUpTestData(cls): cls.manager = create_user(prefix="assignment-manager") cls.program = create_partner_program( - is_distributed_evaluation=True, max_project_rates=3 + is_distributed_evaluation=True, + max_project_rates=3, + datetime_evaluation_ends=NOW + timedelta(days=5), ) cls.program.managers.add(cls.manager) cls.expert = create_rate_expert(program=cls.program) @@ -258,45 +260,103 @@ def test_missing_legacy_submission_timestamp_does_not_invent_waiting(self): self.assertEqual(item["status"], "pending") self.assertTrue(item["project_submitted"]) self.assertIsNone(item["waiting_seconds"]) - self.assertEqual( - self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 - ) + delayed = self.get(self.overview_url)["attention"]["delayed_experts"] + self.assertEqual(delayed["total"], 1) + self.assertIsNone(delayed["items"][0]["oldest_waiting_since"]) + self.assertIsNone(delayed["items"][0]["oldest_waiting_seconds"]) + self.assertEqual(delayed["items"][0]["severity"], "warning") - def test_future_waiting_is_clamped_to_zero_and_not_delayed(self): + def test_future_waiting_is_clamped_to_zero_without_affecting_deadline_signal(self): self.assignment(hours=-5) self.assertEqual(self.get()[0]["waiting_seconds"], 0) - self.assertEqual( - self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 - ) + delayed = self.get(self.overview_url)["attention"]["delayed_experts"] + self.assertEqual(delayed["total"], 1) + self.assertEqual(delayed["items"][0]["oldest_waiting_seconds"], 0) - def test_sla_thresholds_and_boundaries(self): + def test_evaluation_deadline_window_and_boundaries(self): cases = ( - (1, 23, None), - (2, 23, None), - (2, 25, "warning"), - (1, 49, "critical"), - (1, 24, None), - (2, 24, "warning"), - (1, 48, "critical"), - ) - for count, hours, severity in cases: - with self.subTest(count=count, hours=hours): + (timedelta(days=5, seconds=1), 100, None), + (timedelta(days=5), 0, "warning"), + (timedelta(days=5, seconds=-1), 49, "warning"), + (timedelta(days=1), 1, "warning"), + (timedelta(seconds=1), 1, "warning"), + (timedelta(0), 0, "critical"), + (timedelta(seconds=-1), 0, "critical"), + (timedelta(days=-10), 100, "critical"), + ) + for remaining, hours, severity in cases: + with self.subTest(remaining=remaining, hours=hours): + self.program.datetime_evaluation_ends = NOW + remaining + self.program.save(update_fields=["datetime_evaluation_ends"]) ProjectExpertAssignment.objects.all().delete() - for _ in range(count): - self.assignment(hours=hours) + self.assignment(hours=hours) delayed = self.get(self.overview_url)["attention"]["delayed_experts"] self.assertEqual(delayed["total"], int(severity is not None)) if severity is not None: item = delayed["items"][0] self.assertEqual(item["severity"], severity) - self.assertEqual(item["assignments_total"], count) - self.assertEqual(item["pending"], count) + self.assertEqual(item["assignments_total"], 1) + self.assertEqual(item["pending"], 1) self.assertEqual(item["completed"], 0) - self.assertEqual(item["overdue_24h"], count) - self.assertEqual(item["overdue_48h"], count if hours >= 48 else 0) + self.assertEqual(item["overdue_24h"], int(hours >= 24)) + self.assertEqual(item["overdue_48h"], int(hours >= 48)) self.assertEqual(item["oldest_waiting_seconds"], hours * 3600) - def test_completed_and_not_ready_do_not_trigger_sla(self): + def test_old_24h_and_48h_thresholds_do_not_trigger_before_deadline_window(self): + self.program.datetime_evaluation_ends = NOW + timedelta(days=6) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assignment(hours=25) + self.assignment(hours=25) + self.assignment(hours=100) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_missing_evaluation_deadline_does_not_use_other_program_dates(self): + self.program.datetime_evaluation_ends = None + self.program.datetime_finished = NOW + self.program.datetime_registration_ends = NOW + self.program.datetime_project_submission_ends = NOW + self.program.save() + self.assignment(hours=100) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_completing_scores_removes_signal_and_moving_deadline_recalculates_it(self): + assignment = self.assignment(hours=0) + self.score(assignment, 1) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 1 + ) + self.program.datetime_evaluation_ends = NOW + timedelta(days=6) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + self.program.datetime_evaluation_ends = NOW + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["items"][0][ + "severity" + ], + "critical", + ) + # Saving the program also ensures its default Comment criterion exists. + for criterion in Criteria.objects.filter(partner_program=self.program): + ProjectScore.objects.get_or_create( + criteria=criterion, + project_id=assignment.project_id, + user_id=assignment.expert.user_id, + defaults={"value": "0"}, + ) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_completed_and_not_ready_do_not_trigger_deadline_signal(self): self.score(self.assignment(hours=100)) self.assignment(hours=100, submitted=False) self.assertEqual( @@ -315,7 +375,7 @@ def test_delayed_expert_totals_include_completed_and_not_ready(self): self.assertEqual(item["overdue_48h"], 1) self.assertEqual(item["oldest_waiting_seconds"], 49 * 3600) - def test_other_program_assignments_do_not_leak_into_sla_or_list(self): + def test_other_program_assignments_do_not_leak_into_deadline_signal_or_list(self): other = create_partner_program(is_distributed_evaluation=True) self.expert.expert.programs.add(other) self.assignment(hours=100, program=other) @@ -334,17 +394,26 @@ def test_open_mode_never_returns_delayed_experts(self): ) self.assertEqual(len(self.get()), 1) # Real assignments are still available. - def test_delayed_experts_sort_by_severity_wait_and_id(self): + def test_delayed_experts_sort_by_known_waiting_then_duration_and_id(self): warning = create_rate_expert(program=self.program) for _ in range(2): self.assignment(expert=warning, hours=25) self.assignment(hours=49) oldest = create_rate_expert(program=self.program) self.assignment(expert=oldest, hours=60) + tied = create_rate_expert(program=self.program) + self.assignment(expert=tied, hours=60) + unknown = create_rate_expert(program=self.program) + unknown_assignment = self.assignment(expert=unknown, hours=100) + PartnerProgramProject.objects.filter(project=unknown_assignment.project).update( + datetime_submitted=None + ) items = self.get(self.overview_url)["attention"]["delayed_experts"]["items"] self.assertEqual( - [item["user_id"] for item in items], [oldest.pk, self.expert.pk, warning.pk] + [item["user_id"] for item in items], + [oldest.pk, tied.pk, self.expert.pk, warning.pk, unknown.pk], ) + self.assertTrue(all(item["severity"] == "warning" for item in items)) def test_manager_staff_superuser_access_to_both_endpoints(self): assignment = self.assignment()