Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,20 @@ overview, scoring, team/invite rules, фильтры, сохранение field
`PartnerProgramProject.submitted` / `datetime_submitted` и дата создания назначения.
Модели, запись оценок и поведение сдачи проекта не меняются.

`criteria_total` — число критериев программы. `criteria_scored` — число DISTINCT
Внутренние аннотации: `criteria_total` — число критериев программы,
`criteria_scored` — число DISTINCT
критериев этой программы, по которым существует строка оценки именно этого
пользователя и проекта. Оценки другой программы/эксперта/проекта не учитываются.
Строка со значением `"0"` или пустым/nullable значением считается существующей
оценкой; аналитика не вводит новую валидацию `ProjectScore.value`.
Создаваемый текущим signal критерий «Комментарий» типа `str` также входит в
общее число критериев: исключения по названию или типу не вводятся.

Количество оценённых критериев — внутренняя деталь вычисления статуса назначения.
`criteria_total` и `criteria_scored` не входят в публичный assignment object:
их нет ни в списке назначений, ни в ответе `/assignments/<assignment_id>/scores/`.
Детализация `scores` с критериями и фактическими оценками сохраняется.

| Условие | status |
| --- | --- |
| Проект не сдан в этой программе | `not_ready` |
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions partner_programs/services/assignment_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 19 additions & 11 deletions partner_programs/tests/test_assignment_analytics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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])
Expand Down Expand Up @@ -438,15 +441,16 @@ def test_assignment_has_explicit_safe_contract(self):
"expert",
"project",
"status",
"criteria_total",
"criteria_scored",
"assigned_at",
"project_submitted",
"project_submitted_at",
"waiting_since",
"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"},
Expand Down Expand Up @@ -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],
Expand Down
Loading