diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index e5a04581..1e695621 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -184,7 +184,8 @@ overview, scoring, team/invite rules, фильтры, сохранение field `PartnerProgramProject.submitted` / `datetime_submitted` и дата создания назначения. Модели, запись оценок и поведение сдачи проекта не меняются. -`criteria_total` — число критериев программы. `criteria_scored` — число DISTINCT +Внутренние аннотации: `criteria_total` — число критериев программы, +`criteria_scored` — число DISTINCT критериев этой программы, по которым существует строка оценки именно этого пользователя и проекта. Оценки другой программы/эксперта/проекта не учитываются. Строка со значением `"0"` или пустым/nullable значением считается существующей @@ -192,6 +193,11 @@ overview, scoring, team/invite rules, фильтры, сохранение field Создаваемый текущим signal критерий «Комментарий» типа `str` также входит в общее число критериев: исключения по названию или типу не вводятся. +Количество оценённых критериев — внутренняя деталь вычисления статуса назначения. +`criteria_total` и `criteria_scored` не входят в публичный assignment object: +их нет ни в списке назначений, ни в ответе `/assignments//scores/`. +Детализация `scores` с критериями и фактическими оценками сохраняется. + | Условие | status | | --- | --- | | Проект не сдан в этой программе | `not_ready` | @@ -243,8 +249,6 @@ overview, scoring, team/invite rules, фильтры, сохранение field }, "project": {"id": 55, "name": "Проект А"}, "status": "in_progress", - "criteria_total": 3, - "criteria_scored": 1, "assigned_at": "2026-09-03T10:00:00Z", "project_submitted": true, "project_submitted_at": "2026-09-03T12:00:00Z", diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index b8320193..4bbac6c9 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -81,14 +81,14 @@ class ProgramAssignmentScopeSerializer(serializers.Serializer): class ProgramAssignmentSerializer(serializers.Serializer): + """Публичное назначение: счётчики критериев остаются внутри расчёта статуса.""" + assignment_id = serializers.IntegerField() expert = AssignmentExpertSerializer() project = AssignmentProjectSerializer() status = serializers.ChoiceField( choices=("not_ready", "pending", "in_progress", "completed") ) - criteria_total = serializers.IntegerField(min_value=0) - criteria_scored = serializers.IntegerField(min_value=0) assigned_at = serializers.DateTimeField() project_submitted = serializers.BooleanField() project_submitted_at = serializers.DateTimeField(allow_null=True) diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py index 74e1b28f..2b862b19 100644 --- a/partner_programs/services/assignment_analytics.py +++ b/partner_programs/services/assignment_analytics.py @@ -86,6 +86,11 @@ def assignment_rows(program_id): def build_assignment(row, *, now): + """Возвращает назначение без внутренних счётчиков критериев. + + Аннотации total/scored нужны для прежнего правила завершённости и статуса, + но не входят в публичные данные списка и детализации оценок. + """ total, scored = row["criteria_total"], row["criteria_scored"] if not row["project_submitted"]: status = "not_ready" @@ -118,8 +123,6 @@ def build_assignment(row, *, now): }, "project": {"id": row["project_id"], "name": row["project__name"]}, "status": status, - "criteria_total": total, - "criteria_scored": scored, "assigned_at": row["datetime_created"], "project_submitted": row["project_submitted"], "project_submitted_at": submitted_at, diff --git a/partner_programs/tests/test_assignment_analytics_api.py b/partner_programs/tests/test_assignment_analytics_api.py index a43bea33..2e4f0eed 100644 --- a/partner_programs/tests/test_assignment_analytics_api.py +++ b/partner_programs/tests/test_assignment_analytics_api.py @@ -104,15 +104,15 @@ def test_zero_one_two_three_criteria_scores_map_to_statuses(self): self.score(assignment, count) item = self.get()[0] self.assertEqual(item["status"], expected) - self.assertEqual(item["criteria_total"], 3) - self.assertEqual(item["criteria_scored"], count) + self.assertNotIn("criteria_total", item) + self.assertNotIn("criteria_scored", item) def test_zero_criteria_is_pending_not_completed(self): Criteria.objects.filter(partner_program=self.program).delete() - self.assignment() + assignment = self.assignment() item = self.get()[0] self.assertEqual(item["status"], "pending") - self.assertEqual(item["criteria_total"], 0) + self.assertEqual(self.get(self.scores_url(assignment))["scores"], []) def test_unsubmitted_even_with_scores_is_not_ready(self): assignment = self.assignment(submitted=False) @@ -133,7 +133,11 @@ def test_duplicate_criterion_score_is_rejected_and_cannot_inflate_progress(self) user=self.expert, value="8", ) - self.assertEqual(self.get()[0]["criteria_scored"], 1) + self.assertEqual(self.get()[0]["status"], "in_progress") + self.assertEqual( + [item["value"] for item in self.get(self.scores_url(assignment))["scores"]], + ["0", None, None], + ) def test_unrelated_program_user_and_project_scores_are_ignored(self): assignment = self.assignment() @@ -157,7 +161,6 @@ def test_unrelated_program_user_and_project_scores_are_ignored(self): ) self.score(assignment, 1) item = self.get()[0] - self.assertEqual(item["criteria_scored"], 1) self.assertEqual(item["status"], "in_progress") scores = self.get(self.scores_url(assignment))["scores"] self.assertEqual([item["value"] for item in scores], ["0", None, None]) @@ -438,8 +441,6 @@ def test_assignment_has_explicit_safe_contract(self): "expert", "project", "status", - "criteria_total", - "criteria_scored", "assigned_at", "project_submitted", "project_submitted_at", @@ -447,6 +448,9 @@ def test_assignment_has_explicit_safe_contract(self): "waiting_seconds", }, ) + self.assertEqual(set(build_assignments(self.program.pk)[0]), set(item)) + detail = self.get(self.scores_url(assignment)) + self.assertEqual(set(detail), set(item) | {"scores"}) self.assertEqual( set(item["expert"]), {"expert_id", "user_id", "first_name", "last_name", "full_name", "avatar"}, @@ -474,14 +478,18 @@ def test_avatar_is_url_and_blank_names_do_not_break_overview(self): self.assertEqual(expert["avatar"], self.expert.avatar) self.get(self.overview_url) - def test_score_detail_returns_all_criteria_with_progress_and_missing_values(self): + def test_score_detail_keeps_criteria_and_values_without_aggregate_counts(self): assignment = self.assignment() for count in (0, 1, 3): with self.subTest(count=count): self.score(assignment, count) detail = self.get(self.scores_url(assignment)) - self.assertEqual(detail["criteria_scored"], count) - self.assertEqual(detail["criteria_total"], 3) + self.assertNotIn("criteria_scored", detail) + self.assertNotIn("criteria_total", detail) + self.assertEqual( + detail["status"], + {0: "pending", 1: "in_progress", 3: "completed"}[count], + ) scores = detail["scores"] self.assertEqual( [item["criterion_id"] for item in scores],