diff --git a/docs/project-analytics-api.md b/docs/project-analytics-api.md index 21bc3b48..7c46b29a 100644 --- a/docs/project-analytics-api.md +++ b/docs/project-analytics-api.md @@ -344,8 +344,6 @@ synthetic assignment generation. }, "project": {"id": 42, "name": "Project"}, "status": "in_progress", - "criteria_total": 5, - "criteria_scored": 2, "assigned_at": "2026-09-01T12:00:00Z", "project_submitted": true, "project_submitted_at": "2026-09-02T12:00:00Z", @@ -361,6 +359,11 @@ least one but not all current criteria scored; otherwise a submitted assignment is `pending`, including zero criteria. Adding a criterion can make a previously completed assignment incomplete. No new lifecycle semantics are introduced. +Счётчики `criteria_total` / `criteria_scored` не входят в публичный объект +назначения ни в списке, ни в детализации `/scores/`. Внутренние SQL-аннотации +сохранены: по ним по-прежнему определяются завершённость и статус назначения. +Overview, delayed experts, ожидание/SLA и «Завершили: X из Y» не меняются. + Only the six listed expert fields and project ID/name are exposed, without full User/Project serializers, email, phone, auth data or personal forms. @@ -381,7 +384,7 @@ current-program criteria in criterion-PK order: A missing score is `value=null, is_scored=false`. An existing score retains its exact string/null value and has `is_scored=true`, including blank strings, whitespace and null. There is no numeric conversion, trimming or averaging. -Scores of another expert, Project or program cannot affect progress or values. +Оценки другого эксперта, проекта или программы не влияют на статус и значения. Unknown or foreign-program assignment IDs return 404, even for a manager of both programs. Lookup is scoped before resolving the assignment ID. diff --git a/partner_programs/serializers/project_assignment_analytics.py b/partner_programs/serializers/project_assignment_analytics.py index 3a1a6ab3..3f943858 100644 --- a/partner_programs/serializers/project_assignment_analytics.py +++ b/partner_programs/serializers/project_assignment_analytics.py @@ -1,4 +1,4 @@ -"""SQL-free, explicit contracts for legacy assignment analytics.""" +"""Явные контракты legacy-назначений без SQL и внутренних счётчиков критериев.""" from rest_framework import serializers @@ -30,8 +30,6 @@ class ProjectAssignmentAnalyticsSerializer(serializers.Serializer): 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/project_assignment_analytics.py b/partner_programs/services/project_assignment_analytics.py index 5b476858..70df7a46 100644 --- a/partner_programs/services/project_assignment_analytics.py +++ b/partner_programs/services/project_assignment_analytics.py @@ -90,7 +90,11 @@ def assignment_rows(program_id): def build_assignment(row, *, now): - """Shared completion and waiting semantics for overview and drilldowns.""" + """Формирует публичное назначение с общими правилами статуса и ожидания. + + Счётчики критериев остаются внутренними SQL-аннотациями: они нужны + для статуса, но не входят в контракт списка или детализации оценки. + """ total, scored = row["criteria_total"], row["criteria_scored"] if not row["project_submitted"]: status = "not_ready" @@ -104,7 +108,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. + # Без исторической даты сдачи нельзя достоверно определить начало SLA. 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())) @@ -123,8 +127,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_project_assignment_analytics_api.py b/partner_programs/tests/test_project_assignment_analytics_api.py index 2a0267a5..43ad1713 100644 --- a/partner_programs/tests/test_project_assignment_analytics_api.py +++ b/partner_programs/tests/test_project_assignment_analytics_api.py @@ -147,8 +147,14 @@ def test_zero_one_and_all_five_scores_and_new_criterion(self): self.score(assignment, count) item = self.get()[0] self.assertEqual(item["status"], status) - self.assertEqual(item["criteria_total"], 5) - self.assertEqual(item["criteria_scored"], count) + self.assertNotIn("criteria_total", item) + self.assertNotIn("criteria_scored", item) + detail = self.get(self.scores_url(assignment.pk)) + self.assertNotIn("criteria_total", detail) + self.assertNotIn("criteria_scored", detail) + self.assertEqual(detail["status"], status) + self.assertEqual(len(detail["scores"]), 5) + self.assertEqual(sum(row["is_scored"] for row in detail["scores"]), count) metrics = self.get(self.overview_url)["evaluation_status"]["assignments"] self.assertEqual( metrics, @@ -164,7 +170,8 @@ def test_zero_criteria_is_pending(self): self.program.criterias.all().delete() self.assignment() item = self.get()[0] - self.assertEqual((item["criteria_total"], item["criteria_scored"]), (0, 0)) + self.assertNotIn("criteria_total", item) + self.assertNotIn("criteria_scored", item) self.assertEqual(item["status"], "pending") def test_unsubmitted_even_with_all_scores_is_not_ready(self): @@ -197,10 +204,9 @@ def test_foreign_expert_project_and_program_scores_do_not_count(self): criteria=foreign_criterion, value="foreign", ) - self.assertEqual(self.get()[0]["criteria_scored"], 0) + self.assertEqual(self.get()[0]["status"], "pending") 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.pk))["scores"] self.assertEqual( @@ -359,7 +365,6 @@ def test_same_project_uses_own_program_link_timestamps_criteria_and_assignment(s row_b = self.get(url_b)[0] self.assertEqual(row_b["assignment_id"], assignment_b.pk) self.assertEqual(row_b["status"], "not_ready") - self.assertEqual(row_b["criteria_scored"], 0) self.assertIsNone(row_b["project_submitted_at"]) timestamp_b = NOW - timedelta(hours=25) PartnerProgramProject.objects.filter(pk=link_b.pk).update( @@ -724,8 +729,6 @@ def test_explicit_safe_contract_and_sql_free_serializers(self): "expert", "project", "status", - "criteria_total", - "criteria_scored", "assigned_at", "project_submitted", "project_submitted_at", @@ -735,6 +738,7 @@ def test_explicit_safe_contract_and_sql_free_serializers(self): ) self.assertEqual(set(payload["expert"]), SAFE_EXPERT_FIELDS) self.assertEqual(set(payload["project"]), {"id", "name"}) + self.assertEqual(set(self.get()[0]), set(payload) - {"scores"}) delayed = self.delayed() self.assertEqual( set(delayed["items"][0]), @@ -759,6 +763,18 @@ def test_explicit_safe_contract_and_sql_free_serializers(self): self.assertTrue(serializer.is_valid(), serializer.errors) self.assertEqual(serializer.data, data) assignments = build_assignments(self.program.pk) + self.assertEqual(set(assignments[0]), set(payload) - {"scores"}) + with self.assertNumQueries(0): + for serializer_class, data in ( + (ProjectAssignmentAnalyticsSerializer, assignments[0]), + (ProjectAssignmentScoresSerializer, payload), + ): + # Даже внутренние аннотации на входе не должны утечь в публичный DTO. + public = serializer_class( + {**data, "criteria_total": 5, "criteria_scored": 0} + ).data + self.assertNotIn("criteria_total", public) + self.assertNotIn("criteria_scored", public) with self.assertNumQueries(0): self.assertEqual(build_delayed_experts(assignments)["total"], 1) @@ -767,8 +783,6 @@ def test_strict_status_severity_and_nonnegative_counts(self): item = self.get()[0] for field, value in ( ("status", "evaluated"), - ("criteria_total", -1), - ("criteria_scored", -1), ("waiting_seconds", -1), ): with self.subTest(field=field), self.assertNumQueries(0):