From bc9105a1e0371bb3719e2d41cfd81cfbb392396d Mon Sep 17 00:00:00 2001 From: Fedor Date: Sun, 6 Sep 2026 02:32:52 +0300 Subject: [PATCH] DEV: add not-submitted projects attention drilldown --- docs/program-manager-analytics-api.md | 122 ++++++ partner_programs/serializers/analytics.py | 7 + partner_programs/serializers/attention.py | 17 + partner_programs/services/analytics.py | 37 +- .../tests/test_not_submitted_analytics_api.py | 402 ++++++++++++++++++ partner_programs/urls.py | 6 + partner_programs/views.py | 46 +- 7 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 partner_programs/tests/test_not_submitted_analytics_api.py diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 821ce6f2..e91344e9 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -506,3 +506,125 @@ Regression-бюджет для manager с уже аутентифицирова Angular, shared app-modal, production, workflows и Docker в этом этапе не меняются. Будущему UI разрешены только существующие переходы к профилю/проекту; сообщения, напоминания, подбор команды, назначения экспертов и новые выгрузки в v1 отсутствуют. + +## Проекты не сдали решение + +### Аудит единиц измерения: почему «Сдали проект» = 2, а «Сдано» = 6 + +`participant_funnel.submitted_project_creators` считает **уникальные user_id +зарегистрированных участников**, руководящих хотя бы одним сданным проектом +текущей программы. Удалённые пользователи (`user_id=null`) не учитываются. +`solution_funnel.submitted` считает **сданные связи PartnerProgramProject**. +Например, два зарегистрированных руководителя с тремя сданными проектами у +каждого дают соответственно 2 человека и 6 связей. Это разные единицы, не +ошибка одного счётчика. Оба существующих API-поля и их расчёт сохранены. +Удаление дублирующей строки из воронки участников — отдельная задача Angular, +не изменение backend-контракта. Число регистраций также не подменяет число людей. + +### Счётчик и применимость + +В `manager-overview.attention` добавлено read-only поле: + +```json +"projects_not_submitted": {"applicable": true, "total": 4} +``` + +Требование сдачи применимо только при `PartnerProgram.is_competitive=true`. +В этом случае одна несданная связь текущей программы (`submitted=false`) — одна +строка. Публичность и draft проекта, его команда, руководитель и назначения +экспертов не меняют включение. Сдача этого же проекта в другой программе не +влияет на текущую связь. Проекты без связи с программой исключены. + +При одном состоянии БД и без поиска выполняется: + +`attention.projects_not_submitted.total == solution_funnel.not_submitted == count`. + +Все три используют общий `_not_submitted_filter()`: сводка повторно использует +уже рассчитанный агрегат воронки, без нового SQL. Список использует тот же +предикат через `projects_not_submitted_rows()`. + +Для несоревновательной программы счётчик — `{"applicable": false, "total": 0}`: +несданные связи не являются проблемой. Старый `solution_funnel.not_submitted` +не пересчитывается и может оставаться ненулевым. + +### Endpoint и безопасные поля + +`GET /programs//manager-overview/projects-not-submitted/` + +Доступ: manager **этой** программы, staff, superuser через существующий +`can_manage_program`. Anonymous — `401`; участник, эксперт без роли manager, +manager другой программы и посторонний — `403`; отсутствующая программа — `404`. +Для авторизованного manager POST/PUT/PATCH/DELETE возвращают `405`. +Endpoint не меняет состояния сдачи и не даёт новых возможностей записи. + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "program_project_id": 70, + "project": {"id": 55, "name": "Проект А"}, + "leader": {"user_id": 123, "full_name": "Анна Петрова", "avatar": null}, + "linked_at": "2026-09-01T10:00:00+03:00" + } + ], + "applicable": true, + "submission_deadline": "2026-09-10T23:59:00+03:00", + "submission_open": true +} +``` + +`linked_at` — `PartnerProgramProject.datetime_created`, **не** дата создания +Project. Руководитель содержит только разрешённые ID, имя и avatar. Пустое имя +заменяется на `Участник №ID`, пустой avatar — `null`. Контракт защищён от +`leader=null` без изменения текущего NOT NULL ограничения Project.leader. +Нет email, телефона, auth-полей, анкет, файлов, оценок, SLA или полного User/Project +serializer. Режим оценивания не добавляется и не меняет состав списка. + +### Сроки, поиск и пагинация + +`submission_deadline` берётся из существующего +`program.get_project_submission_deadline()`: сначала +`datetime_project_submission_ends`, иначе `datetime_registration_ends`. +`submission_open` — результат существующего `program.is_project_submission_open()`. +Его правило: срок отсутствует либо ещё не прошёл, включая точное равенство +текущему времени. Даты timezone-aware; нового расчёта дедлайнов/SLA нет. + +Несоревновательная программа всегда возвращает: + +```json +{ + "count": 0, + "next": null, + "previous": null, + "results": [], + "applicable": false, + "submission_deadline": null, + "submission_open": false +} +``` + +Повторно используется `ProgramAttentionPagination` и проверка query из #725: +`limit=25` по умолчанию, допустимо 1–100; `offset=0` по умолчанию, допустимо +неотрицательное целое. Некорректное значение, включая `limit>100`, даёт `400`. +Поиск `search` обрезает внешние пробелы и использует только +`project__name__icontains`, до count и SQL-пагинации. По имени руководителя, +email, описанию и приватным данным поиск не выполняется. Пробельная строка +не ограничивает список. Порядок: дата создания связи по возрастанию, затем её pk. +`next`/`previous` сохраняют поиск; offset за концом — `200`, пустые results и +актуальный count. Поиск меняет count найденных строк, но не счётчик сводки. + +### Производительность и ограничения + +Страница загружается через JOIN/select_related с project и leader и явным +набором полей. Сериализация уже выбранных строк выполняет **0 SQL**. Regression +проверяет одинаковое число SQL при росте с 1 до 31 строки с разными руководителями: +не более 4 запросов для аутентифицированного manager, как у списков #725. +Overview сохраняет бюджет не более 10 SQL и прежние контракты assignments, +scores, delayed_experts и двух существующих списков внимания. + +Кейсы, сообщения, напоминания и изменение scoring/submission lifecycle не входят +в этот этап. Модели, migrations, зависимости, Angular/React, workflows, Docker и +deploy не меняются. Новые поля добавлены совместимо с существующим overview. diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 83c4171b..469fcbc8 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -128,9 +128,16 @@ class DelayedExpertsSerializer(AnalyticsTotalSerializer): items = DelayedExpertSerializer(many=True) +class ProjectsNotSubmittedSerializer(AnalyticsTotalSerializer): + """Несданные связи программы; требование сдачи применимо только к конкурсной.""" + + applicable = serializers.BooleanField() + + class ProgramAttentionSerializer(serializers.Serializer): participants_without_team = serializers.IntegerField(min_value=0) projects_awaiting_evaluation = serializers.IntegerField(min_value=0) + projects_not_submitted = ProjectsNotSubmittedSerializer() delayed_experts = DelayedExpertsSerializer() diff --git a/partner_programs/serializers/attention.py b/partner_programs/serializers/attention.py index 5b2a58c1..6a428d82 100644 --- a/partner_programs/serializers/attention.py +++ b/partner_programs/serializers/attention.py @@ -61,6 +61,23 @@ def get_avatar(self, user): return user.avatar or None +class ProgramNotSubmittedProjectSerializer(serializers.Serializer): + """Минимальный read-only контракт несданной связи, без приватных данных.""" + + program_project_id = serializers.IntegerField(source="pk") + project = AssignmentProjectSerializer() + leader = ProgramAttentionLeaderSerializer(source="project.leader", allow_null=True) + linked_at = serializers.DateTimeField(source="datetime_created") + + +class ProgramNotSubmittedMetadataSerializer(serializers.Serializer): + """Срок и доступность сдачи из существующих методов программы, без нового SLA.""" + + applicable = serializers.BooleanField() + submission_deadline = serializers.DateTimeField(allow_null=True) + submission_open = serializers.BooleanField() + + WAITING_REASONS = { "no_assignments": "Эксперты не назначены", "no_completed_evaluations": "Нет завершённых оценок", diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index eb1b2847..38032116 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -145,6 +145,35 @@ def _get_participant_regions(program_id: int) -> list[dict]: ) +def _not_submitted_filter(): + """Единый признак несданной связи проекта для воронки, счётчика и списка.""" + return Q(submitted=False) + + +def projects_not_submitted_rows(program): + """Несданные связи соревновательной программы, независимо от свойств проекта. + + Несоревновательная программа не требует сдачи: список для неё неприменим. + Выбираются только публичные поля руководителя; сериализация не делает SQL. + """ + rows = PartnerProgramProject.objects.filter( + _not_submitted_filter(), partner_program_id=program.pk + ) + if not program.is_competitive: + return rows.none() + return rows.select_related("project", "project__leader").only( + "id", + "project_id", + "datetime_created", + "project__name", + "project__leader_id", + "project__leader__id", + "project__leader__first_name", + "project__leader__last_name", + "project__leader__avatar", + ) + + def _solution_rows(program): """Общая SQL-классификация работ программы для overview и детализации. @@ -202,7 +231,7 @@ def _solution_rows(program): ) return rows.annotate( status=Case( - When(submitted=False, then=Value("not_submitted")), + When(_not_submitted_filter(), then=Value("not_submitted")), default=evaluated_status, output_field=CharField(), ) @@ -237,7 +266,7 @@ def projects_awaiting_evaluation_rows(program): def _get_solution_metrics(program) -> dict[str, int]: return _solution_rows(program).aggregate( created=Count("pk"), - not_submitted=Count("pk", filter=Q(submitted=False)), + not_submitted=Count("pk", filter=_not_submitted_filter()), submitted=Count("pk", filter=Q(submitted=True)), awaiting_evaluation=Count("pk", filter=Q(status="awaiting_evaluation")), partially_evaluated=Count("pk", filter=Q(status="partially_evaluated")), @@ -349,6 +378,10 @@ def build_program_manager_analytics(program) -> dict: "attention": { "participants_without_team": participants["without_team"], "projects_awaiting_evaluation": projects_awaiting_evaluation, + "projects_not_submitted": { + "applicable": program.is_competitive, + "total": solutions["not_submitted"] if program.is_competitive else 0, + }, "delayed_experts": ( build_delayed_experts(assignment_items) if program.is_distributed_evaluation diff --git a/partner_programs/tests/test_not_submitted_analytics_api.py b/partner_programs/tests/test_not_submitted_analytics_api.py new file mode 100644 index 00000000..8449f347 --- /dev/null +++ b/partner_programs/tests/test_not_submitted_analytics_api.py @@ -0,0 +1,402 @@ +"""Контракт несданных связей программы: применимость, доступ и SQL-пагинация.""" + +from datetime import datetime, timedelta, timezone as datetime_timezone +from types import SimpleNamespace +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils.dateparse import parse_datetime +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from partner_programs.serializers.attention import ProgramNotSubmittedProjectSerializer +from partner_programs.services.analytics import projects_not_submitted_rows +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import ProjectExpertAssignment +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator, Project + + +NOW = datetime(2026, 9, 6, 12, tzinfo=datetime_timezone.utc) + + +class ProjectsNotSubmittedAnalyticsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(prefix="not-submitted-manager") + cls.leader = create_user(first_name="Анна", last_name="Петрова", avatar=None) + cls.program = create_partner_program(is_competitive=True) + cls.program.managers.add(cls.manager) + cls.other_program = create_partner_program(is_competitive=True) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = self.url_for(self.program.pk) + self.overview_url = reverse( + "partner_programs:manager-overview", kwargs={"pk": self.program.pk} + ) + + def url_for(self, program_id): + return reverse( + "partner_programs:manager-overview-projects-not-submitted", + kwargs={"pk": program_id}, + ) + + def get(self, **params): + response = self.client.get(self.url, params) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def work(self, *, program=None, submitted=False, hours=1, **project_fields): + project_fields.setdefault("leader", self.leader) + link = create_program_project( + program or self.program, + project=create_project(**project_fields), + submitted=submitted, + ) + PartnerProgramProject.objects.filter(pk=link.pk).update( + datetime_created=NOW - timedelta(hours=hours) + ) + return link + + def test_empty_competitive_program_is_applicable_with_zero_count(self): + data = self.get() + self.assertTrue(data["applicable"]) + self.assertEqual(data["count"], 0) + self.assertEqual(data["results"], []) + self.assertEqual( + self.client.get(self.overview_url).data["attention"][ + "projects_not_submitted" + ], + {"applicable": True, "total": 0}, + ) + + def test_only_unsent_current_program_links_are_included_once(self): + included = self.work() + submitted = self.work(submitted=True) + self.work(program=self.other_program, name="OtherOnlyNeedle") + create_project(name="UnlinkedOnlyNeedle", leader=self.leader) + create_program_project( + self.other_program, project=included.project, submitted=True + ) + create_program_project( + self.other_program, project=submitted.project, submitted=False + ) + data = self.get() + self.assertEqual(data["count"], 1) + self.assertEqual( + [row["program_project_id"] for row in data["results"]], [included.pk] + ) + self.assertEqual(self.get(search="OtherOnlyNeedle")["count"], 0) + self.assertEqual(self.get(search="UnlinkedOnlyNeedle")["count"], 0) + + def test_draft_publicity_team_and_assignments_do_not_change_membership(self): + expert = create_rate_expert(program=self.program) + expected = [] + for draft in (True, False): + for is_public in (True, False): + link = self.work(draft=draft, is_public=is_public) + expected.append(link.pk) + create_program_member(self.program, user=self.manager) + Collaborator.objects.create(project=link.project, user=self.manager, role="Test") + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=link.project, expert=expert.expert + ) + self.assertEqual(self.get()["count"], 4) + self.assertEqual( + [row["program_project_id"] for row in self.get()["results"]], expected + ) + + def test_detail_attention_and_funnel_counts_match_in_both_evaluation_modes(self): + for _ in range(4): + self.work() + self.work(submitted=True) + for distributed in (True, False): + with self.subTest(distributed=distributed): + self.program.is_distributed_evaluation = distributed + self.program.save(update_fields=["is_distributed_evaluation"]) + overview = self.client.get(self.overview_url) + self.assertEqual(overview.status_code, 200) + self.assertEqual(overview.data["solution_funnel"]["not_submitted"], 4) + self.assertEqual( + overview.data["attention"]["projects_not_submitted"], + {"applicable": True, "total": self.get()["count"]}, + ) + self.assertEqual(overview.data["solution_funnel"]["submitted"], 1) + + def test_noncompetitive_unsent_links_are_not_an_attention_problem(self): + self.work() + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.assertEqual( + self.get(), + { + "count": 0, + "next": None, + "previous": None, + "results": [], + "applicable": False, + "submission_deadline": None, + "submission_open": False, + }, + ) + overview = self.client.get(self.overview_url).data + self.assertEqual(overview["solution_funnel"]["not_submitted"], 1) + self.assertEqual( + overview["attention"]["projects_not_submitted"], + {"applicable": False, "total": 0}, + ) + + def test_funnel_units_remain_two_registered_creators_and_six_submitted_links(self): + leaders = [self.leader, create_user()] + for leader in leaders: + create_program_member(self.program, user=leader) + for _ in range(3): + self.work(leader=leader, submitted=True) + overview = self.client.get(self.overview_url).data + self.assertEqual(overview["participant_funnel"]["submitted_project_creators"], 2) + self.assertEqual(overview["solution_funnel"]["submitted"], 6) + + def test_explicit_deadline_precedes_registration_fallback(self): + self.program.datetime_registration_ends = NOW - timedelta(days=1) + self.program.datetime_project_submission_ends = NOW + timedelta(days=1) + self.program.save( + update_fields=[ + "datetime_registration_ends", + "datetime_project_submission_ends", + ] + ) + with patch("partner_programs.models.timezone.now", return_value=NOW): + data = self.get() + self.assertEqual( + parse_datetime(data["submission_deadline"]), NOW + timedelta(days=1) + ) + self.assertTrue(data["submission_open"]) + + def test_deadline_fallback_and_open_boundary_are_timezone_aware(self): + for seconds, is_open in ((-1, False), (0, True), (1, True)): + with self.subTest(seconds=seconds): + deadline = (NOW + timedelta(seconds=seconds)).astimezone( + datetime_timezone(timedelta(hours=3)) + ) + self.program.datetime_project_submission_ends = None + self.program.datetime_registration_ends = deadline + self.program.save( + update_fields=[ + "datetime_registration_ends", + "datetime_project_submission_ends", + ] + ) + with patch("partner_programs.models.timezone.now", return_value=NOW): + data = self.get() + self.assertEqual(parse_datetime(data["submission_deadline"]), deadline) + self.assertIsNotNone(parse_datetime(data["submission_deadline"]).tzinfo) + self.assertEqual(data["submission_open"], is_open) + + def test_past_explicit_deadline_closes_even_when_registration_is_open(self): + self.program.datetime_project_submission_ends = NOW - timedelta(days=1) + self.program.datetime_registration_ends = NOW + timedelta(days=1) + self.program.save( + update_fields=[ + "datetime_registration_ends", + "datetime_project_submission_ends", + ] + ) + with patch("partner_programs.models.timezone.now", return_value=NOW): + data = self.get() + self.assertFalse(data["submission_open"]) + self.assertEqual( + parse_datetime(data["submission_deadline"]), NOW - timedelta(days=1) + ) + + def test_safe_contract_and_link_date_not_project_date(self): + link = self.work(name="Проект А") + Project.objects.filter(pk=link.project_id).update( + datetime_created=NOW - timedelta(days=100) + ) + data = self.get() + self.assertEqual( + set(data), + { + "count", + "next", + "previous", + "results", + "applicable", + "submission_deadline", + "submission_open", + }, + ) + item = data["results"][0] + self.assertEqual( + set(item), {"program_project_id", "project", "leader", "linked_at"} + ) + self.assertEqual(item["program_project_id"], link.pk) + self.assertEqual(item["project"], {"id": link.project_id, "name": "Проект А"}) + self.assertEqual( + item["leader"], + {"user_id": self.leader.pk, "full_name": "Анна Петрова", "avatar": None}, + ) + self.assertEqual(parse_datetime(item["linked_at"]), NOW - timedelta(hours=1)) + + def test_empty_leader_name_has_neutral_fallback(self): + self.leader.first_name = " " + self.leader.last_name = "" + self.leader.avatar = "" + self.leader.save(update_fields=["first_name", "last_name", "avatar"]) + self.work() + self.assertEqual( + self.get()["results"][0]["leader"], + { + "user_id": self.leader.pk, + "full_name": f"Участник №{self.leader.pk}", + "avatar": None, + }, + ) + + def test_null_leader_contract_without_relaxing_database_schema(self): + # Project.leader remains NOT NULL; defensive serialization alone permits null. + link = SimpleNamespace( + pk=1, + project=SimpleNamespace(id=2, name="Legacy", leader=None), + datetime_created=NOW, + ) + with self.assertNumQueries(0): + data = ProgramNotSubmittedProjectSerializer(link).data + self.assertIsNone(data["leader"]) + + def test_serialization_of_selected_rows_performs_no_queries(self): + self.work() + rows = list(projects_not_submitted_rows(self.program)) + with self.assertNumQueries(0): + data = ProgramNotSubmittedProjectSerializer(rows, many=True).data + self.assertEqual(len(data), 1) + + def test_trimmed_case_insensitive_search_is_applied_before_count_and_page(self): + self.work(name="Unrelated", hours=5) + first = self.work(name="Alpha project", hours=4) + second = self.work(name="ALPHA second", hours=3) + self.work(program=self.other_program, name="Alpha foreign", hours=6) + data = self.get(search=" aLpHa ", limit=1) + self.assertEqual(data["count"], 2) + self.assertEqual(data["results"][0]["program_project_id"], first.pk) + params = parse_qs(urlparse(data["next"]).query) + self.assertEqual(params["search"], [" aLpHa "]) + self.assertEqual(params["offset"], ["1"]) + page = self.get(search="alpha", limit=1, offset=1) + self.assertEqual(page["results"][0]["program_project_id"], second.pk) + self.assertIsNone(page["next"]) + self.assertIsNotNone(page["previous"]) + + def test_search_does_not_include_leader_email_or_private_project_text(self): + self.work(name="Public title", description="PrivateDescriptionNeedle") + for search in ( + self.leader.email, + self.leader.first_name, + "PrivateDescriptionNeedle", + ): + with self.subTest(search=search): + self.assertEqual(self.get(search=search)["count"], 0) + self.assertEqual(self.get(search=" ")["count"], 1) + + def test_sort_is_link_creation_then_link_id_and_is_repeatable(self): + newest = self.work(hours=1) + older = self.work(hours=2) + tied = self.work(hours=2) + for _ in range(2): + self.assertEqual( + [item["program_project_id"] for item in self.get()["results"]], + [older.pk, tied.pk, newest.pk], + ) + + def test_default_limit_max_limit_and_offset_beyond_count(self): + for _ in range(31): + self.work() + first = self.get() + self.assertEqual(first["count"], 31) + self.assertEqual(len(first["results"]), 25) + self.assertIsNone(first["previous"]) + self.assertIsNotNone(first["next"]) + self.assertEqual(len(self.get(limit=100)["results"]), 31) + beyond = self.get(offset=999) + self.assertEqual(beyond["results"], []) + self.assertEqual(beyond["count"], 31) + + def test_invalid_pagination_returns_400_with_field_error(self): + for field, values in ( + ("limit", (0, -1, 101, "abc", "1.5", "")), + ("offset", (-1, "abc", "1.5", "")), + ): + for value in values: + with self.subTest(field=field, value=value): + response = self.client.get(self.url, {field: value}) + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.data) + + def test_manager_staff_and_superuser_have_access(self): + for user in ( + self.manager, + create_user(is_staff=True), + create_user(is_superuser=True), + ): + with self.subTest(user=user.pk): + self.client.force_authenticate(user) + self.get() + + def test_anonymous_is_unauthorized(self): + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.url).status_code, 401) + + def test_participant_expert_and_other_manager_cannot_access_or_search(self): + participant = create_program_member(self.program).user + expert = create_rate_expert(program=self.program) + other_manager = create_user() + self.other_program.managers.add(other_manager) + for user in (participant, expert, other_manager, create_user()): + with self.subTest(user=user.pk): + self.client.force_authenticate(user) + self.assertEqual( + self.client.get(self.url, {"search": "Project"}).status_code, 403 + ) + + def test_current_manager_cannot_read_another_program(self): + self.assertEqual( + self.client.get(self.url_for(self.other_program.pk)).status_code, 403 + ) + + def test_missing_program_is_not_found(self): + self.assertEqual(self.client.get(self.url_for(99999999)).status_code, 404) + + def test_write_methods_are_405_and_do_not_change_submission(self): + link = self.work() + for method in ("post", "put", "patch", "delete"): + with self.subTest(method=method): + response = getattr(self.client, method)( + self.url, {"submitted": True}, format="json" + ) + self.assertEqual(response.status_code, 405) + link.refresh_from_db() + self.assertFalse(link.submitted) + + def test_query_count_is_constant_from_one_to_31_rows(self): + self.work() + with CaptureQueriesContext(connection) as small: + self.assertEqual(self.get(limit=100)["count"], 1) + for _ in range(30): + self.work(leader=create_user()) + with CaptureQueriesContext(connection) as large: + data = self.get(limit=100) + self.assertEqual(len(data["results"]), 31) + self.assertEqual(len(small), len(large)) + self.assertLessEqual(len(large), 4) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 9bc11081..855e3733 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -13,6 +13,7 @@ ProgramManagerAssignmentScoresAPIView, ProgramManagerParticipantsWithoutTeamAPIView, ProgramManagerProjectsAwaitingEvaluationAPIView, + ProgramManagerProjectsNotSubmittedAPIView, PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, @@ -44,6 +45,11 @@ ProgramManagerProjectsAwaitingEvaluationAPIView.as_view(), name="manager-overview-projects-awaiting-evaluation", ), + path( + "/manager-overview/projects-not-submitted/", + ProgramManagerProjectsNotSubmittedAPIView.as_view(), + name="manager-overview-projects-not-submitted", + ), path( "/manager-overview/assignments/", ProgramManagerAssignmentsAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 215daba5..58933e2d 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -69,10 +69,13 @@ ProgramAttentionParticipantSerializer, ProgramAttentionProjectSerializer, ProgramAttentionQuerySerializer, + ProgramNotSubmittedMetadataSerializer, + ProgramNotSubmittedProjectSerializer, ) from partner_programs.services.analytics import ( participants_without_team_rows, projects_awaiting_evaluation_rows, + projects_not_submitted_rows, ) from partner_programs.services.assignment_analytics import ( assignment_rows, @@ -501,7 +504,7 @@ def get(self, request, pk, assignment_id): class ProgramManagerAttentionListAPIView(ProgramManagerAnalyticsAccessAPIView): - """Общий read-only доступ и пагинация только двух новых списков внимания.""" + """Общий read-only доступ и пагинация списков внимания.""" def get(self, request, pk): """Проверяет управление программой до поиска, подсчёта и сериализации страницы.""" @@ -514,12 +517,17 @@ def get(self, request, pk): response = paginator.get_paginated_response( self.serializer_class(page, many=True).data ) - if self.include_mode: - response.data["mode"] = ( - "distributed" if program.is_distributed_evaluation else "open" - ) + response.data.update(self.get_metadata(program)) return response + def get_metadata(self, program): + """Сохраняет метаданные существующих списков; новые задаются локально.""" + if self.include_mode: + return { + "mode": "distributed" if program.is_distributed_evaluation else "open" + } + return {} + class ProgramManagerParticipantsWithoutTeamAPIView(ProgramManagerAttentionListAPIView): """Уникальные зарегистрированные пользователи без команды именно этой программы.""" @@ -551,6 +559,34 @@ def get_queryset(self, program, search): return queryset.order_by(F("datetime_submitted").asc(nulls_last=True), "pk") +class ProgramManagerProjectsNotSubmittedAPIView(ProgramManagerAttentionListAPIView): + """Read-only список несданных связей только текущей соревновательной программы.""" + + serializer_class = ProgramNotSubmittedProjectSerializer + + def get_queryset(self, program, search): + """Поиск по названию до count/page; старые связи первыми, затем их pk.""" + queryset = projects_not_submitted_rows(program) + if search: + queryset = queryset.filter(project__name__icontains=search) + return queryset.order_by("datetime_created", "pk") + + def get_metadata(self, program): + """Повторно использует действующий контракт сроков сдачи программы.""" + applicable = program.is_competitive + return ProgramNotSubmittedMetadataSerializer( + { + "applicable": applicable, + "submission_deadline": ( + program.get_project_submission_deadline() if applicable else None + ), + "submission_open": ( + program.is_project_submission_open() if applicable else False + ), + } + ).data + + class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram]