From 0e6a01552606c0b4e9f44bcef757cfa1cfed37c0 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 13 Oct 2026 16:12:26 +0300 Subject: [PATCH] fix(analytics): filter case projects and export matching rows --- docs/analytics-case-drilldown-api.md | 17 ++- .../serializers/project_case_drilldown.py | 15 ++- partner_programs/services/exports.py | 6 +- .../services/project_case_drilldown.py | 11 ++ .../tests/test_project_case_drilldown.py | 111 +++++++++++++++++- partner_programs/views.py | 12 +- 6 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs/analytics-case-drilldown-api.md b/docs/analytics-case-drilldown-api.md index fa8df32b..15f46669 100644 --- a/docs/analytics-case-drilldown-api.md +++ b/docs/analytics-case-drilldown-api.md @@ -18,11 +18,11 @@ Q1–Q4 закрыты, Figma section `30:1518` утверждена. Полна ## Запросы ```text -GET /programs/{id}/projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}&limit=25&offset=0&search={query} +GET /programs/{id}/projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}&limit=25&offset=0&search={query}&submission=submitted&presentation=missing GET /programs/{id}/projects/?view=case_analytics&case_scope=without_case GET /programs/{id}/projects/?view=case_analytics&case_scope=all -GET /programs/{id}/export-projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption} +GET /programs/{id}/export-projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}&search={query}&submission=submitted&presentation=missing GET /programs/{id}/export-projects/?view=case_analytics&case_scope=without_case GET /programs/{id}/export-projects/?view=case_analytics&case_scope=all ``` @@ -34,15 +34,22 @@ GET /programs/{id}/export-projects/?view=case_analytics&case_scope=all - List: limit по умолчанию 25, диапазон 1–100; offset по умолчанию 0, минимум 0. Search ищет подстроку в имени проекта без учёта регистра; внешние пробелы удаляются. Порядок: `datetime_created ASC, pk ASC` связи. -- Export не принимает search, limit, offset, only_submitted, включая пустые значения. - Выгружается полный bucket независимо от поиска и страницы. +- List и export поддерживают одинаковый отбор (добавлено 13.10.2026): + `search` по имени проекта, `submission=all|submitted|not_submitted`, + `presentation=all|present|missing`. Фильтры по умолчанию `all`, сочетаются через AND. + Сдача определяется `PartnerProgramProject.submitted` выбранной программы. + Презентация есть, если `Project.presentation_address` содержит непробельный символ; + NULL, пустая строка и только пробелы означают отсутствие. Доступность URL не проверяется. + В noncompetitive программе submission, отличный от all, возвращает 400. +- Export не принимает limit, offset, only_submitted, включая пустые значения. + Выгружаются все совпадения поиска и фильтров независимо от текущей страницы. - Без параметров нового режима прежние serializer, pagination и XLSX, включая only_submitted, сохраняются. Generic filter и rates export не затронуты. ## Ответ списка Поля страницы: count, next, previous, results, selection, cases_configured, -submission_applicable, case_metrics. Count учитывает search; case_metrics описывают весь bucket: +submission_applicable, case_metrics. Count учитывает поиск и фильтры; case_metrics описывают весь bucket: projects_total, participants_total, submitted, not_submitted. Для all case_metrics = null. Selection = `{scope, case_name}`; имя вне selected — null. diff --git a/partner_programs/serializers/project_case_drilldown.py b/partner_programs/serializers/project_case_drilldown.py index 50aded3e..79214ba5 100644 --- a/partner_programs/serializers/project_case_drilldown.py +++ b/partner_programs/serializers/project_case_drilldown.py @@ -17,6 +17,13 @@ class ProjectCaseQuerySerializer(serializers.Serializer): view = serializers.ChoiceField(choices=("case_analytics",)) case_scope = serializers.ChoiceField(choices=("all", "selected", "without_case")) case_name = serializers.CharField(required=False, trim_whitespace=False) + search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True) + submission = serializers.ChoiceField( + choices=("all", "submitted", "not_submitted"), default="all" + ) + presentation = serializers.ChoiceField( + choices=("all", "present", "missing"), default="all" + ) def to_internal_value(self, data): errors = { @@ -34,6 +41,13 @@ def to_internal_value(self, data): return super().to_internal_value({key: data[key] for key in data}) def validate(self, attrs): + if ( + attrs["submission"] != "all" + and self.context.get("submission_applicable") is False + ): + raise serializers.ValidationError( + {"submission": "В этой программе сдача решений не требуется."} + ) if attrs["case_scope"] == "selected": if attrs.get("case_name") not in self.context["case_options"]: raise serializers.ValidationError( @@ -49,7 +63,6 @@ def validate(self, attrs): class ProjectCaseListQuerySerializer(ProjectCaseQuerySerializer): limit = serializers.IntegerField(min_value=1, max_value=100, default=25) offset = serializers.IntegerField(min_value=0, default=0) - search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True) class CaseProjectSerializer(serializers.Serializer): diff --git a/partner_programs/services/exports.py b/partner_programs/services/exports.py index a2ddb7e4..623a9a76 100644 --- a/partner_programs/services/exports.py +++ b/partner_programs/services/exports.py @@ -368,7 +368,7 @@ def _literal_excel_cell(worksheet, value): def build_case_projects_export_file(*, program, field, selection) -> ProgramExportFile: - """Выгружает весь bucket; pagination/search в этот контракт не входят.""" + """Выгружает все совпадения поиска и фильтров выбранного кейса без пагинации.""" workbook = Workbook(write_only=True) worksheet = workbook.create_sheet(title="Проекты") worksheet.append(CASE_PROJECT_COLUMNS) @@ -505,9 +505,7 @@ def prepare_project_scores_export_data(program_id: int) -> list[dict]: for _, expert_scores in scores_by_expert.items(): row_data: dict[str, str] = {} - row_data["Название проекта"] = ( - getattr(project, "name", "") if project else "" - ) + row_data["Название проекта"] = getattr(project, "name", "") if project else "" row_data["Фамилия эксперта"] = ( expert_scores[0].user.last_name if expert_scores else "" ) diff --git a/partner_programs/services/project_case_drilldown.py b/partner_programs/services/project_case_drilldown.py index 04e054fc..eb1e3544 100644 --- a/partner_programs/services/project_case_drilldown.py +++ b/partner_programs/services/project_case_drilldown.py @@ -15,6 +15,17 @@ def case_project_rows(program, *, field, selection): elif selection["case_scope"] == "without_case": links = links.filter(case_name__isnull=True) + if selection.get("search"): + links = links.filter(project__name__icontains=selection["search"]) + submission = selection.get("submission", "all") + if submission != "all": + links = links.filter(submitted=submission == "submitted") + presentation = selection.get("presentation", "all") + if presentation == "present": + links = links.filter(project__presentation_address__regex=r"\S") + elif presentation == "missing": + links = links.exclude(project__presentation_address__regex=r"\S") + collaborators = ( Collaborator.objects.filter(project_id=OuterRef("project_id")) .order_by() diff --git a/partner_programs/tests/test_project_case_drilldown.py b/partner_programs/tests/test_project_case_drilldown.py index 91dc4bbe..f9a88752 100644 --- a/partner_programs/tests/test_project_case_drilldown.py +++ b/partner_programs/tests/test_project_case_drilldown.py @@ -413,11 +413,120 @@ def test_query_validation_rejects_invalid_and_ambiguous_scope_without_fallback(s {"offset": ""}, ): self.assertEqual(self.get(**params).status_code, 400) - for key in ("search", "limit", "offset", "only_submitted"): + for key in ("limit", "offset", "only_submitted"): for value in ("", "1"): self.assertEqual(self.get(export=True, **{key: value}).status_code, 400) self.assertEqual(self.page(limit=100)["count"], 1) + def test_combined_filters_search_pagination_and_export_use_same_selection(self): + links = [] + for submitted in (False, True): + for address in (None, "", " \t\n", "https://example.org/deck.pdf"): + project = create_project( + leader=self.manager, + name=f"Keep project {len(links)}", + presentation_address=address, + ) + links.append(self.link("A", project=project, submitted=submitted)) + self.link( + "B", + project=create_project( + leader=self.manager, + name="Keep foreign case", + presentation_address="https://example.org/deck.pdf", + ), + submitted=True, + ) + self.link("A", project=create_project(leader=self.manager, name="Other name")) + foreign = create_partner_program(is_competitive=True) + foreign_field = create_case_field(foreign, options=["A"]) + self.link("A", project=links[0].project, program=foreign, field=foreign_field) + + for submission in ("all", "submitted", "not_submitted"): + for presentation in ("all", "present", "missing"): + with self.subTest(submission=submission, presentation=presentation): + expected = [ + link + for link in links + if ( + submission == "all" + or link.submitted == (submission == "submitted") + ) + and ( + presentation == "all" + or bool((link.project.presentation_address or "").strip()) + == (presentation == "present") + ) + ] + query = { + "submission": submission, + "presentation": presentation, + "search": " kEeP ", + } + page = self.page(limit=1, offset=1, **query) + self.assertEqual(page["count"], len(expected)) + self.assertEqual(page["case_metrics"]["projects_total"], 9) + self.assertEqual( + [row["program_project_id"] for row in page["results"]], + [link.pk for link in expected[1:2]], + ) + _, rows, _ = self.workbook(**query) + self.assertEqual( + [row[1] for row in rows], [link.project.name for link in expected] + ) + + def test_filters_work_for_without_case_and_all_scopes(self): + selected = self.link("A", submitted=True) + missing = self.link(None, submitted=True) + self.link(None, submitted=False) + for scope, expected in ( + ("without_case", [missing]), + ("all", [selected, missing]), + ): + page = self.page(scope, submission="submitted", presentation="missing") + self.assertEqual( + [row["program_project_id"] for row in page["results"]], + [link.pk for link in expected], + ) + _, rows, _ = self.workbook( + scope, submission="submitted", presentation="missing" + ) + self.assertEqual( + [row[1] for row in rows], [link.project.name for link in expected] + ) + + def test_invalid_filters_and_duplicate_values_are_rejected_by_list_and_export(self): + for export in (False, True): + for key in ("submission", "presentation"): + for value in ("", "true", "unknown"): + self.assertEqual( + self.get(export=export, **{key: value}).status_code, 400 + ) + query = f"?view=case_analytics&case_scope=all&{key}=all&{key}=all" + self.assertEqual( + self.client.get(self.url(export) + query).status_code, 400 + ) + + def test_noncompetitive_submission_filter_is_not_applicable(self): + program = create_partner_program(is_competitive=False) + program.managers.add(self.manager) + self.link(program=program) + self.assertEqual(self.page("without_case", program=program)["count"], 1) + for export in (False, True): + for submission in ("submitted", "not_submitted"): + self.assertEqual( + self.get( + "without_case", + export=export, + program=program, + submission=submission, + ).status_code, + 400, + ) + self.assertEqual( + self.page("without_case", program=program, presentation="missing")["count"], 1 + ) + def test_permission_matrix_missing_program_and_read_only_methods(self): self.link( "A", project=create_project(leader=self.manager, name="Private case project") diff --git a/partner_programs/views.py b/partner_programs/views.py index 57170d40..c1b40781 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -607,13 +607,14 @@ def list(self, request, *args, **kwargs): field = get_program_case_field(program) query = ProjectCaseListQuerySerializer( data=request.query_params, - context={"case_options": field.get_options_list() if field else []}, + context={ + "case_options": field.get_options_list() if field else [], + "submission_applicable": program.is_competitive, + }, ) query.is_valid(raise_exception=True) selection = query.validated_data rows = case_project_rows(program, field=field, selection=selection) - if selection["search"]: - rows = rows.filter(project__name__icontains=selection["search"]) paginator = ProgramAttentionPagination(selection) page = paginator.paginate_queryset(rows, request, view=self) response = paginator.get_paginated_response( @@ -695,7 +696,10 @@ def get(self, request, pk: int): field = get_program_case_field(program) query = ProjectCaseQuerySerializer( data=request.query_params, - context={"case_options": field.get_options_list() if field else []}, + context={ + "case_options": field.get_options_list() if field else [], + "submission_applicable": program.is_competitive, + }, ) query.is_valid(raise_exception=True) export_file = build_case_projects_export_file(