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
17 changes: 12 additions & 5 deletions docs/analytics-case-drilldown-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand 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.

Expand Down
15 changes: 14 additions & 1 deletion partner_programs/serializers/project_case_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(
Expand All @@ -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):
Expand Down
6 changes: 2 additions & 4 deletions partner_programs/services/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
)
Expand Down
11 changes: 11 additions & 0 deletions partner_programs/services/project_case_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
111 changes: 110 additions & 1 deletion partner_programs/tests/test_project_case_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions partner_programs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading