From d7cbb8846997212f623efab20680ebc8d701a1e6 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 7 Sep 2026 10:35:00 +0300 Subject: [PATCH] Add program case analytics to manager overview --- docs/program-case-field.md | 41 ++- docs/program-manager-analytics-api.md | 70 +++- partner_programs/serializers/analytics.py | 19 ++ partner_programs/services/analytics.py | 2 + partner_programs/services/case_analytics.py | 81 +++++ .../tests/test_assignment_analytics_api.py | 3 +- .../tests/test_case_analytics_api.py | 318 ++++++++++++++++++ .../tests/test_manager_analytics_api.py | 18 +- 8 files changed, 538 insertions(+), 14 deletions(-) create mode 100644 partner_programs/services/case_analytics.py create mode 100644 partner_programs/tests/test_case_analytics_api.py diff --git a/docs/program-case-field.md b/docs/program-case-field.md index 994e2129..7c33509f 100644 --- a/docs/program-case-field.md +++ b/docs/program-case-field.md @@ -212,5 +212,42 @@ Project. Manager/expert/staff не получают право записи. Ч критерии и scoring не изменены. Тесты проверяют A, B, A+B и независимые значения одного Project в разных программах. -Analytics по кейсам, отдельная модель кейса, автоматическое исправление старых -данных и frontend — следующие отдельные этапы, не часть этого PR. +## Аналитика проектов по системному кейсу + +Существующий `GET /programs/{programId}/manager-overview/` дополнен top-level +блоком `cases` (полный контракт — в [analytics docs](program-manager-analytics-api.md#кейсы)). +Новый endpoint или drilldown не добавляются; права manager/staff/superuser прежние. + +- `configured` — наличие definition с exact `name=PROGRAM_CASE_FIELD_NAME` (`case`). + Label, тип, show_filter и похожие имена (`Case`, `cases`, `case_name`, `кейс`) + не определяют системный кейс. +- `items` содержит **все текущие options**, включая нулевые строки, в порядке + конфигурации. Сохранённое значение сравнивается с option точно, без исправления + опечаток, изменения регистра или trim выбранного value. +- `projects_total` считает связи Project × Program именно этой программы; + `submitted` / `not_submitted` используют только флаг связи `submitted`. +- `without_case` содержит отсутствующий/пустой/null результат выбора и значения + вне актуальных options. При отсутствии definition все связи попадают сюда, + `configured=false`, `items=[]`. Исторические данные не изменяются и не теряются. + `value_text` сейчас NOT NULL; null результата также возникает при отсутствии + строки значения в коррелированном подзапросе. +- `participants_total` — уникальные зарегистрированные в этой программе + пользователи, являющиеся лидером или Collaborator хотя бы одного проекта + bucket. Orphan profiles, непринадлежащие программе пользователи, приглашения + без членства и одно лишь поле project в анкете не считаются. Лидер учитывается + без Collaborator; дубли ролей/проектов внутри bucket не увеличивают счётчик. + Один пользователь может входить в разные cases и without_case: сумму этих + значений **нельзя считать глобальным числом уникальных участников**. +- `submission_applicable=program.is_competitive`. Это metadata UI; даже при false + счётчики сохраняют raw flags, `projects_total = submitted + not_submitted`. + +Суммы `items + without_case` по projects_total/submitted/not_submitted точно +соответствуют solution_funnel.created/submitted/not_submitted. Helper делает +четыре фиксированных SQL-запроса независимо от числа options/связей: definition, +сгруппированные счётчики, пары лидеров и пары collaborators. Регистрации проверяются +SQL IN subquery; множества user_id объединяются внутри bucket. Для 1 и 20 options +число запросов одинаково; сериализаторы SQL не выполняют. + +Analytics только читает существующие данные. Case validation, option protection, +canonical GET/PUT, submission, admin и остальные поля overview не меняются. +Отдельная Case model, миграции, export/drilldown по кейсам и frontend не добавляются. diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index e91344e9..e5a04581 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -43,6 +43,15 @@ Endpoint доступен менеджерам указанной програм "submitted": 2, "evaluated": 1 }, + "cases": { + "configured": true, + "submission_applicable": true, + "items": [ + {"name": "Кейс A", "participants_total": 2, "projects_total": 2, "not_submitted": 0, "submitted": 2}, + {"name": "Кейс B", "participants_total": 0, "projects_total": 0, "not_submitted": 0, "submitted": 0} + ], + "without_case": {"participants_total": 0, "projects_total": 0, "not_submitted": 0, "submitted": 0} + }, "evaluation_status": { "mode": "distributed", "max_evaluations_per_project": 2, @@ -118,12 +127,55 @@ Endpoint доступен менеджерам указанной програм ## Кейсы -В текущей модели нет отдельной сущности или обязательной связи «кейс». -Произвольные `PartnerProgramField` могут иметь похожее название, но не являются -стабильным системным контрактом. Поэтому `cases` в ответ не добавляется. -Для такой аналитики нужна отдельная модель кейса и явная внешняя связь -`PartnerProgramProject` с выбранным кейсом либо утверждённое системное поле с -гарантированным идентификатором. +Top-level `cases` — аддитивный блок существующего manager overview, без нового +endpoint и без изменений разрешений. Источник — [системное поле](program-case-field.md) +с exact `name=PROGRAM_CASE_FIELD_NAME` (`case`). Другие select/label/имена не +используются как эвристика. + +`configured` сообщает наличие definition. `items` следует текущему порядку +`get_options_list()` и включает options с нулём проектов. Сравнение сохранённого +`PartnerProgramFieldValue.value_text` с option точное. Значения относятся только +к конкретной связи и definition текущей программы, не к Project глобально. + +В каждом item и `without_case`: + +- `projects_total` — число связей `PartnerProgramProject`; +- `submitted`, `not_submitted` — разбиение по raw boolean `submitted` связи, + независимо от draft/public проекта и datetime_submitted; +- `participants_total` — число уникальных ненулевых user_id зарегистрированных + `PartnerProgramUserProfile` этой программы, состоящих в команде через leader + или Collaborator. Приглашения и одно лишь project в регистрационной анкете + членства не образуют. Пользователи других программ без регистрации здесь + исключены. Лидер и collaborator, а также несколько проектов одного bucket, + не дублируют пользователя. Один человек может учитываться в нескольких buckets; + сумма participants_total не обязана равняться summary.participants.total. + +`without_case` собирает отсутствующее/null/пустое значение и устаревшие значения +вне options. При отсутствии system field: `configured=false`, `items=[]`, все +связи и соответствующие участники остаются в `without_case`. Похожий generic +select не используется вместо case. Никакие исторические данные не исправляются. + +`submission_applicable = program.is_competitive` — подсказка для UI. При false +счётчики всё равно отражают реальные submitted flags; не создаются искусственные +сдачи. Для каждого bucket `projects_total = submitted + not_submitted`. + +Инварианты для любой программы, в том числе без definition: + +```text +sum(items.projects_total) + without_case.projects_total = solution_funnel.created +sum(items.submitted) + without_case.submitted = solution_funnel.submitted +sum(items.not_submitted) + without_case.not_submitted = solution_funnel.not_submitted +``` + +`build_case_analytics(program)` добавляет **4 SQL-запроса**, независимо от количества +options: definition, grouped counts, distinct case/user пары лидеров и collaborators. +Фильтрация зарегистрированных пользователей — IN subquery без размножения строк. +Сериализаторы явные, все счётчики IntegerField(min_value=0), без SQL. Тест сравнивает +1 и 20 options (в том числе HTTP overview); общий бюджет manager overview увеличен +с 10 до 14 запросов, прежняя проверка независимости от числа проектов сохранена. + +Новых моделей, миграций, endpoint/drilldown/export по кейсам нет. Остальные поля +overview, scoring, team/invite rules, фильтры, сохранение fields и submission не меняются. ## Статусы назначений и проектов @@ -329,7 +381,7 @@ SQL SELECT: связанные таблицы через JOIN, число кри Score drilldown добавляет два фиксированных запроса (критерии и оценки пары). Regression query budget для manager с уже аутентифицированным request.user: -список — 3 SQL, overview — 10 SQL, scores — 5 SQL; не растёт при переходе +список — 3 SQL, overview с cases — 14 SQL, scores — 5 SQL; не растёт при переходе от 1 к 31 назначению. JWT/session-аутентификация может добавить свои запросы. Проверяются SQLite и PostgreSQL; новых моделей, индексов и миграций нет. @@ -496,7 +548,7 @@ SerializerMethodField форматирует только уже загруже Regression-бюджет для manager с уже аутентифицированным `request.user`: не более 4 SQL на непустую страницу (программа, проверка manager, count, строки страницы). Он проверяется при росте каждого списка с 1 до 31 строки, для работ — также -с ростом назначений. Счётчик сводки сохраняет прежний бюджет не более 10 SQL. +с ростом назначений. Overview с блоком cases использует не более 14 SQL. Запросы JWT/session-аутентификации могут добавляться отдельно. Проверка кириллического регистронезависимого поиска выполняется на PostgreSQL: SQLite по умолчанию не поддерживает эквивалентный Unicode case-fold. @@ -622,7 +674,7 @@ email, описанию и приватным данным поиск не вы набором полей. Сериализация уже выбранных строк выполняет **0 SQL**. Regression проверяет одинаковое число SQL при росте с 1 до 31 строки с разными руководителями: не более 4 запросов для аутентифицированного manager, как у списков #725. -Overview сохраняет бюджет не более 10 SQL и прежние контракты assignments, +Overview с cases использует не более 14 SQL и сохраняет прежние контракты assignments, scores, delayed_experts и двух существующих списков внимания. Кейсы, сообщения, напоминания и изменение scoring/submission lifecycle не входят diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 469fcbc8..b8320193 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -147,6 +147,24 @@ class ProgramActivityItemSerializer(serializers.Serializer): submitted_solutions = serializers.IntegerField(min_value=0) +class ProgramCaseAnalyticsMetricsSerializer(serializers.Serializer): + participants_total = serializers.IntegerField(min_value=0) + projects_total = serializers.IntegerField(min_value=0) + not_submitted = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + + +class ProgramCaseAnalyticsItemSerializer(ProgramCaseAnalyticsMetricsSerializer): + name = serializers.CharField(trim_whitespace=False) + + +class ProgramCaseAnalyticsSerializer(serializers.Serializer): + configured = serializers.BooleanField() + submission_applicable = serializers.BooleanField() + items = ProgramCaseAnalyticsItemSerializer(many=True) + without_case = ProgramCaseAnalyticsMetricsSerializer() + + class ProgramManagerAnalyticsSerializer(serializers.Serializer): summary = ProgramAnalyticsSummarySerializer() participant_funnel = ProgramParticipantFunnelSerializer() @@ -154,3 +172,4 @@ class ProgramManagerAnalyticsSerializer(serializers.Serializer): evaluation_status = ProgramEvaluationStatusSerializer() attention = ProgramAttentionSerializer() activity = ProgramActivityItemSerializer(many=True) + cases = ProgramCaseAnalyticsSerializer() diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 38032116..a7aca785 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -12,6 +12,7 @@ build_assignments, build_delayed_experts, ) +from partner_programs.services.case_analytics import build_case_analytics from project_rates.models import ProjectScore ACTIVITY_DAYS = 30 @@ -389,4 +390,5 @@ def build_program_manager_analytics(program) -> dict: ), }, "activity": _get_activity(program_id), + "cases": build_case_analytics(program), } diff --git a/partner_programs/services/case_analytics.py b/partner_programs/services/case_analytics.py new file mode 100644 index 00000000..f0879b9a --- /dev/null +++ b/partner_programs/services/case_analytics.py @@ -0,0 +1,81 @@ +"""Read-only case analytics on existing Project × Program relations.""" + +from django.db.models import Count, OuterRef, Subquery, TextField, Value + +from partner_programs.models import ( + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.services.case_fields import get_program_case_field + + +def _empty_metrics(): + return { + "participants_total": 0, + "projects_total": 0, + "not_submitted": 0, + "submitted": 0, + } + + +def build_case_analytics(program) -> dict: + """Count program links by exact current options of the system field name='case'. + + Missing/empty/obsolete choices share without_case; every link is counted once, + using only its submitted flag, including in noncompetitive programs. Options + retain configuration order and zero rows. Team participants are leaders or + collaborators registered in THIS program, unique within each bucket. A user + may appear in several cases, so participant totals are not globally additive. + Four SQL queries (definition, link counts, leader pairs, collaborator pairs), + independent of option count; serializers perform no queries. + """ + field = get_program_case_field(program) + options = field.get_options_list() if field else [] + buckets = {name: _empty_metrics() for name in options} + without_case = _empty_metrics() + participant_ids = {name: set() for name in buckets} + participant_ids[None] = set() + + choice = ( + Subquery( + PartnerProgramFieldValue.objects.filter( + program_project_id=OuterRef("pk"), field_id=field.pk + ).values("value_text")[:1] + ) + if field + else Value(None, output_field=TextField()) + ) + links = ( + PartnerProgramProject.objects.filter(partner_program_id=program.pk) + .order_by() + .annotate(case_value=choice) + ) + for row in links.values("case_value", "submitted").annotate(total=Count("pk")): + metrics = buckets.get(row["case_value"], without_case) + metrics["projects_total"] += row["total"] + metrics["submitted" if row["submitted"] else "not_submitted"] += row["total"] + + registered_users = PartnerProgramUserProfile.objects.filter( + partner_program_id=program.pk, user_id__isnull=False + ).values("user_id") + # IN subqueries do not multiply memberships if legacy profiles are duplicated. + for user_path in ("project__leader_id", "project__collaborator__user_id"): + pairs = ( + links.filter(**{f"{user_path}__in": registered_users}) + .values_list("case_value", user_path) + .distinct() + ) + for case_value, user_id in pairs: + bucket = case_value if case_value in buckets else None + participant_ids[bucket].add(user_id) + + for name, metrics in buckets.items(): + metrics["participants_total"] = len(participant_ids[name]) + without_case["participants_total"] = len(participant_ids[None]) + return { + "configured": field is not None, + "submission_applicable": program.is_competitive, + "items": [{"name": name, **metrics} for name, metrics in buckets.items()], + "without_case": without_case, + } diff --git a/partner_programs/tests/test_assignment_analytics_api.py b/partner_programs/tests/test_assignment_analytics_api.py index b8e2346b..a43bea33 100644 --- a/partner_programs/tests/test_assignment_analytics_api.py +++ b/partner_programs/tests/test_assignment_analytics_api.py @@ -531,7 +531,8 @@ def test_query_counts_are_constant_for_one_and_many_assignments(self): with CaptureQueriesContext(connection) as queries: self.get(url) counts.append(len(queries)) - self.assertEqual(counts, [3, 10, 5]) + # Only overview grows by the four fixed case-analytics queries. + self.assertEqual(counts, [3, 14, 5]) for _ in range(30): expert = create_rate_expert(program=self.program) self.score(self.assignment(expert=expert, hours=49), 1) diff --git a/partner_programs/tests/test_case_analytics_api.py b/partner_programs/tests/test_case_analytics_api.py new file mode 100644 index 00000000..fb3f9606 --- /dev/null +++ b/partner_programs/tests/test_case_analytics_api.py @@ -0,0 +1,318 @@ +"""Case analytics reconciles links and deduplicates registered team participants.""" + +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from invites.models import Invite +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.serializers.analytics import ( + ProgramCaseAnalyticsSerializer, + ProgramManagerAnalyticsSerializer, +) +from partner_programs.services.analytics import build_program_manager_analytics +from partner_programs.services.case_analytics import build_case_analytics +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field +from projects.models import Collaborator + + +class ProgramCaseAnalyticsTests(TestCase): + def setUp(self): + self.program = create_partner_program(is_competitive=True) + self.manager = create_user() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def overview(self, program=None): + response = self.client.get( + reverse( + "partner_programs:manager-overview", + kwargs={"pk": (program or self.program).pk}, + ) + ) + self.assertEqual(response.status_code, 200) + self.assert_reconciled(response.data) + return response.data + + def assert_reconciled(self, overview): + cases = overview["cases"] + buckets = [*cases["items"], cases["without_case"]] + for bucket in buckets: + self.assertEqual( + bucket["projects_total"], bucket["not_submitted"] + bucket["submitted"] + ) + for metric, funnel_metric in ( + ("projects_total", "created"), + ("submitted", "submitted"), + ("not_submitted", "not_submitted"), + ): + self.assertEqual( + sum(bucket[metric] for bucket in buckets), + overview["solution_funnel"][funnel_metric], + ) + + def link(self, field=None, value=None, *, submitted=False, project=None): + link = create_program_project(self.program, project=project) + if field is not None and value is not None: + # Bypass write guards only to seed historical/corrupted analytics data. + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=link, field=field, value_text=value + ) + ] + ) + PartnerProgramProject.objects.filter(pk=link.pk).update(submitted=submitted) + return link + + def test_basic_counts_options_order_zero_row_and_funnel_reconciliation(self): + field = create_case_field(self.program, options=["Case A", "Case B", "Case C"]) + self.link(field, "Case A", submitted=True) + self.link(field, "Case A") + self.link(field, "Case B", submitted=True) + self.link() + self.link(field, "Legacy") + cases = self.overview()["cases"] + self.assertTrue(cases["configured"]) + self.assertTrue(cases["submission_applicable"]) + self.assertEqual( + cases["items"], + [ + dict(name="Case A", **self.metrics(2, 1)), + dict(name="Case B", **self.metrics(1, 1)), + dict(name="Case C", **self.metrics(0, 0)), + ], + ) + self.assertEqual(cases["without_case"], self.metrics(2, 0)) + + @staticmethod + def metrics(projects, submitted, participants=0): + return { + "participants_total": participants, + "projects_total": projects, + "not_submitted": projects - submitted, + "submitted": submitted, + } + + def test_membership_is_unique_within_case_and_registered_in_this_program(self): + field = create_case_field(self.program) + leader, second, third, outsider, invited, profile_only = [ + create_user() for _ in range(6) + ] + for user in (leader, second, third, invited, profile_only): + create_program_member(self.program, user=user) + create_program_member(create_partner_program(), user=outsider) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, user=None, partner_program_data={} + ) + first = self.link(field, "A", project=create_project(leader=leader)) + second_link = self.link(field, "A", project=create_project(leader=leader)) + for project, user in ( + (first.project, leader), + (first.project, second), + (second_link.project, second), + (second_link.project, third), + ): + Collaborator.objects.get_or_create(project=project, user=user) + # Only bypass validation to represent a pre-existing outsider membership. + Collaborator.objects.bulk_create( + [Collaborator(project=first.project, user=outsider)] + ) + # The leader must count even without the collaborator row added by signals. + Collaborator.objects.filter(project=second_link.project, user=leader).delete() + Invite.objects.create(project=first.project, user=invited) + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, user=profile_only + ).update(project=first.project) + overview = self.overview() + self.assertEqual( + overview["cases"]["items"][0], dict(name="A", **self.metrics(2, 0, 3)) + ) + self.assertEqual(overview["summary"]["participants"]["total"], 5) + + def test_profile_uniqueness_is_enforced_and_does_not_inflate_participants(self): + field = create_case_field(self.program) + user = create_user() + create_program_member(self.program, user=user) + self.link(field, "A", project=create_project(leader=user)) + # Current schema forbids the legacy duplicate-profile fixture. + with self.assertRaises(IntegrityError), transaction.atomic(): + create_program_member(self.program, user=user) + self.assertEqual(self.overview()["cases"]["items"][0]["participants_total"], 1) + + def test_participant_can_count_once_in_each_case_and_without_case(self): + field = create_case_field(self.program) + user = create_user() + create_program_member(self.program, user=user) + for value in ("A", "A", "B", None, "old", ""): + self.link(field, value, project=create_project(leader=user)) + overview = self.overview() + self.assertEqual(overview["summary"]["participants"]["total"], 1) + cases = overview["cases"] + self.assertEqual( + [item["participants_total"] for item in cases["items"]], [1, 1, 0] + ) + self.assertEqual(cases["without_case"], self.metrics(3, 0, 1)) + + def test_missing_empty_whitespace_obsolete_and_nonexact_values_are_without_case(self): + field = create_case_field(self.program) + for index, value in enumerate((None, "", " ", "Legacy", "a", " A ")): + self.link(field, value, submitted=index % 2 == 0) + cases = self.overview()["cases"] + self.assertEqual(cases["without_case"], self.metrics(6, 3)) + self.assertTrue(all(item["projects_total"] == 0 for item in cases["items"])) + + def test_other_program_definition_and_values_cannot_classify_a_link(self): + field = create_case_field(self.program) + other = create_partner_program(is_competitive=True) + other.managers.add(self.manager) + other_field = create_case_field(other) + shared = create_project() + # Same project has separate A/B program links, choices and submitted flags. + first = self.link(field, "A", project=shared, submitted=True) + second = create_program_project(other, project=shared) + PartnerProgramFieldValue.objects.create( + program_project=second, field=other_field, value_text="B" + ) + self.link( + other_field, "B" + ) # Corrupted value pointing at another program's field. + cases = self.overview()["cases"] + self.assertEqual(cases["items"][0]["projects_total"], 1) + self.assertEqual(cases["items"][1]["projects_total"], 0) + self.assertEqual(cases["without_case"]["projects_total"], 1) + other_cases = self.overview(other)["cases"] + self.assertEqual(other_cases["items"][1], dict(name="B", **self.metrics(1, 0))) + self.assertNotEqual(first.pk, second.pk) + + def test_lookalike_system_names_are_not_recognised(self): + for name in ("case_name", "cases", "Case", "кейс"): + with self.subTest(name=name): + program = create_partner_program() + field = create_program_field( + program, + name=name, + label="Кейс", + field_type="select", + options=["A"], + show_filter=True, + ) + link = create_program_project(program) + PartnerProgramFieldValue.objects.create( + program_project=link, field=field, value_text="A" + ) + cases = build_case_analytics(program) + self.assertFalse(cases["configured"]) + self.assertEqual(cases["items"], []) + self.assertEqual(cases["without_case"], self.metrics(1, 0)) + + def test_exact_system_name_is_authoritative_even_for_legacy_definition_flags(self): + field = create_case_field(self.program, label="Unrelated label") + self.link(field, "A") + PartnerProgramField.objects.filter(pk=field.pk).update( + field_type="text", is_required=False, show_filter=False + ) + cases = self.overview()["cases"] + self.assertTrue(cases["configured"]) + self.assertEqual(cases["items"][0]["projects_total"], 1) + + def test_absent_definition_places_all_links_and_participants_without_case(self): + user = create_user() + create_program_member(self.program, user=user) + for index in range(4): + self.link(project=create_project(leader=user), submitted=index < 2) + self.assertEqual( + self.overview()["cases"], + { + "configured": False, + "submission_applicable": True, + "items": [], + "without_case": self.metrics(4, 2, 1), + }, + ) + + def test_zero_projects_keeps_all_options_in_configuration_order(self): + field = create_case_field(self.program, options=["C", "A", "B"]) + cases = self.overview()["cases"] + self.assertEqual( + cases["items"], + [dict(name=name, **self.metrics(0, 0)) for name in ("C", "A", "B")], + ) + self.assertEqual(cases["without_case"], self.metrics(0, 0)) + self.link(field, "A") + self.link(field, "A") + populated = self.overview()["cases"]["items"] + self.assertEqual([item["name"] for item in populated], ["C", "A", "B"]) + self.assertEqual([item["projects_total"] for item in populated], [0, 2, 0]) + + def test_noncompetitive_counts_raw_submitted_flag_not_project_or_timestamp(self): + self.program.is_competitive = False + self.program.save() + field = create_case_field(self.program) + self.link( + field, + "A", + submitted=True, + project=create_project(draft=True, is_public=False), + ) + unsent = self.link( + field, "A", project=create_project(draft=False, is_public=True) + ) + PartnerProgramProject.objects.filter(pk=unsent.pk).update( + datetime_submitted=self.program.datetime_started + ) + cases = self.overview()["cases"] + self.assertTrue(cases["configured"]) + self.assertFalse(cases["submission_applicable"]) + self.assertEqual(cases["items"][0], dict(name="A", **self.metrics(2, 1))) + + def test_fixed_four_query_budget_at_one_and_twenty_options(self): + field = create_case_field(self.program, options=["A"]) + user = create_user() + create_program_member(self.program, user=user) + for _ in range(2): + link = self.link(field, "A", project=create_project(leader=user)) + Collaborator.objects.get_or_create(project=link.project, user=user) + counts = [] + for options in (["A"], ["A", *[f"Case {index}" for index in range(19)]]): + field.options = "|".join(options) + field.save() + with self.assertNumQueries(4): + cases = build_case_analytics(self.program) + self.assertEqual(len(cases["items"]), len(options)) + with CaptureQueriesContext(connection) as queries: + self.overview() + counts.append(len(queries)) + self.assertEqual(counts[0], counts[1]) + self.assertLessEqual(counts[1], 14) + + def test_serializers_preserve_contract_without_sql_and_reject_negative_metrics(self): + create_case_field(self.program) + overview = build_program_manager_analytics(self.program) + with self.assertNumQueries(0): + serializer = ProgramManagerAnalyticsSerializer(data=overview) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.data["cases"], overview["cases"]) + for bucket in (overview["cases"]["items"][0], overview["cases"]["without_case"]): + for metric in self.metrics(0, 0): + with self.subTest(bucket=bucket, metric=metric): + bucket[metric] = -1 + serializer = ProgramCaseAnalyticsSerializer(data=overview["cases"]) + self.assertFalse(serializer.is_valid()) + bucket[metric] = 0 diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index 42ca88da..871314d7 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -471,7 +471,20 @@ def test_empty_program_returns_zero_metrics_and_continuous_activity(self): for item in response.data["activity"] ) ) - self.assertNotIn("cases", response.data) + self.assertEqual( + response.data["cases"], + { + "configured": False, + "submission_applicable": False, + "items": [], + "without_case": { + "participants_total": 0, + "projects_total": 0, + "not_submitted": 0, + "submitted": 0, + }, + }, + ) def test_query_count_does_not_grow_with_program_size(self): with CaptureQueriesContext(connection) as empty_context: @@ -492,4 +505,5 @@ def test_query_count_does_not_grow_with_program_size(self): self.assertEqual(populated_response.status_code, 200) self.assertEqual(len(populated_context), len(empty_context)) - self.assertLessEqual(len(populated_context), 10) + # The additive cases block uses four fixed queries, never one per option. + self.assertLessEqual(len(populated_context), 14)