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
9 changes: 6 additions & 3 deletions docs/project-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.

Expand All @@ -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.

Expand Down
4 changes: 1 addition & 3 deletions partner_programs/serializers/project_assignment_analytics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""SQL-free, explicit contracts for legacy assignment analytics."""
"""Явные контракты legacy-назначений без SQL и внутренних счётчиков критериев."""

from rest_framework import serializers

Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions partner_programs/services/project_assignment_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()))
Expand All @@ -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,
Expand Down
34 changes: 24 additions & 10 deletions partner_programs/tests/test_project_assignment_analytics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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]),
Expand All @@ -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)

Expand All @@ -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):
Expand Down
Loading