From 21f4cdcef6ee5c628d5a4012870e2a6f76cff3e3 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 22:46:25 +0300 Subject: [PATCH] feat: export filtered management projects and reuse canonical score data --- docs/modules/program-management.md | 11 +- partner_programs/management_views.py | 23 +++ partner_programs/services/management.py | 11 ++ .../services/management_projects_excel.py | 66 +++++++++ partner_programs/services/results.py | 20 ++- .../tests/test_management_projects_export.py | 140 ++++++++++++++++++ .../tests/test_management_results_api.py | 36 +++++ partner_programs/urls.py | 6 + 8 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 partner_programs/services/management_projects_excel.py create mode 100644 partner_programs/tests/test_management_projects_export.py diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index 1216430f..d58b7248 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -25,7 +25,7 @@ Списки принимают `search`, `limit` (1–100, по умолчанию 20), `offset` (от 0). Поиск участников — AND по фрагментам, OR между ФИО и почтой; удаленные пользователи не считаются действующими участниками. По умолчанию порядок — дата регистрации/связи и PK. Проекты дополнительно принимают `submission=all|submitted|not_submitted`, `case_scope=all|selected|without_case`, при selected — точный `case_name`. Неправильные параметры возвращают 400, выдача ограничена страницей. -Участники поддерживают `activity=all|period|active|inactive`: active означает наличие `last_activity`, inactive — отсутствие даты, независимо от давности. Прежнее `never` остается alias inactive. Для period нужен хотя бы один из `activity_from` / `activity_to`, границы включают полные московские дни. `order=registration|activity_asc|activity_desc` задает порядок до пагинации; при сортировке активности пустые даты всегда в конце, одинаковые стабилизируются PK. Регион, поиск и активность сочетаются. GET `participants/export/` и `participants/selection/` применяют те же фильтры и порядок ко всем совпадениям, игнорируя пагинацию. Права организатора и формат ответа не меняются; миграций нет. +Участники поддерживают `activity=all|period|active|inactive`: active означает наличие `last_activity`, inactive — отсутствие даты, независимо от давности. Прежнее `never` остается alias inactive. Для period нужен хотя бы один из `activity_from` / `activity_to`, границы включают полные московские дни. `order=registration|activity_asc|activity_desc` задает порядок до пагинации; по умолчанию activity_desc (недавняя активность сверху, пустые даты в конце), для activity_asc пустые даты идут первыми, затем старые даты; одинаковые стабилизируются PK. Регион, поиск и активность сочетаются. GET `participants/export/` и `participants/selection/` применяют те же фильтры и порядок ко всем совпадениям, игнорируя пагинацию. Права организатора и формат ответа не меняются; миграций нет. Оценивание повторно использует `_solution_rows`: в distributed учитываются завершённые реальные назначения; частичная оценка сохраняется отдельно. В open сохраняется существующая семантика наличия оценки по критериям программы. Несданное решение имеет приоритет, неконкурсное — `not_applicable`. Статусы вычисляет сервер. Презентация берётся из `Project.presentation_address` независимо от сдачи. @@ -71,3 +71,12 @@ Angular получает camelCase через существующий intercept Переход к оценке открывает чтение выбранного проекта. Для пользователя с существующими правами эксперта дополнительная ссылка ведёт в legacy rating с точным `project_id`; фильтр не расширяет queryset доступных эксперту проектов. POST оценок и права эксперта не меняются. Порядок внедрения: backend API → Angular UI. Только dev; merge и deployment выполняет пользователь. Отдельные действия участников, массовые операции, возврат проекта на доработку, исключение и смена кейса в V1 не входят. + + +## Уточнения интерфейса проектов · 21.10.2026 + +GET `management/projects/export/` использует тот же ManagementProjectQuery, что список (поиск, кейс, сдача, оценивание), до пагинации. Приватный XLSX содержит все совпадения, включая другие страницы; текстовые данные сохраняются строками без исполнения Excel-формул. Права те же, что для списка. + +`management/projects/:link_id/evaluation/` дополнительно возвращает `criteria` и `result` из канонического расчета Results, ограниченного этой связью программы и проекта. Старые поля `experts`/`can_rate` сохранены. В просмотре организатора используются общая матрица и мобильные карточки Results; отдельная кнопка личного оценивания убрана. Средние и итог учитывают только полные валидные оценки, без изменения правил рейтинга. + +Сданный проект с `draft=True` остается недоступен для назначения экспертов: в таблице показывается «Черновик заявки», а плашка выбора поясняет необходимость завершить оформление заявки. Это не то же, что публичность проекта (`is_public`). Автоматическая публикация и изменение пользовательских данных не выполняются. Фильтры проектов применяются вместе из модального окна; выгрузка и выбор всех используют примененные фильтры. diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 36775563..2846cd54 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -242,6 +242,29 @@ def get(self, request, pk): return response +class ManagementProjectsExportView(ManagementBase): + def get(self, request, pk): + from partner_programs.services.management_projects_excel import export_projects + + program = managed_program(pk, request.user) + field = get_program_case_field(program) + query = ManagementProjectQuery( + data=request.query_params, + context={"case_options": field.get_options_list() if field else []}, + ) + query.is_valid(raise_exception=True) + response = HttpResponse( + export_projects( + management_project_rows(program, query.validated_data, field), program + ), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{pk}-projects.xlsx"' + return response + + class ManagementTeamView(ManagementBase): def get(self, request, pk, link_id): program = managed_program(pk, request.user) diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 011d44ce..20b9faa6 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -234,6 +234,13 @@ def team_data(link): def evaluation_data(program, link, user): + from partner_programs.services.results import results_data + + result_snapshot = results_data( + program, + {"search": "", "case_scope": "all", "order": "desc", "scope": "all"}, + project_link_id=link.pk, + ) criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) scores = list( ProjectScore.objects.filter( @@ -294,4 +301,8 @@ def evaluation_data(program, link, user): "submitted": link.submitted, "can_rate": can_rate, "experts": list(experts.values()), + "criteria": result_snapshot["criteria"], + "result": next( + (p for g in result_snapshot["groups"] for p in g["projects"]), None + ), } diff --git a/partner_programs/services/management_projects_excel.py b/partner_programs/services/management_projects_excel.py new file mode 100644 index 00000000..1c564e3e --- /dev/null +++ b/partner_programs/services/management_projects_excel.py @@ -0,0 +1,66 @@ +"""Export the organizer's filtered projects before pagination.""" + +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + +from partner_programs.services.exports import _literal_excel_cell +from partner_programs.services.management import person_name, project_data + + +def export_projects(rows, program): + workbook = Workbook(write_only=True) + sheet = workbook.create_sheet("Проекты") + sheet.freeze_panes = "C2" + headers = [ + "№", + "Проект", + "Руководитель", + "Почта руководителя", + "Кейс", + "Сдача решения", + "Состояние заявки", + "Оценивание", + "Презентация", + ] + for index, width in enumerate((6, 38, 32, 38, 32, 20, 22, 26, 48), 1): + sheet.column_dimensions[get_column_letter(index)].width = width + cells = [_literal_excel_cell(sheet, title) for title in headers] + for cell in cells: + cell.font = Font(bold=True) + cell.fill = PatternFill("solid", fgColor="EEE8FB") + sheet.append(cells) + statuses = { + "evaluated": "Оценено", + "partially_evaluated": "Частично оценено", + "awaiting_evaluation": "Не оценено", + "not_submitted": "—", + "not_applicable": "Не требуется", + } + number = 0 + required = program.required_evaluators or program.max_project_rates or 1 + for number, link in enumerate(rows.iterator(chunk_size=500), 1): + data = project_data(link, program) + status = statuses.get(data["evaluation_status"], "—") + if data["evaluation_status"] == "partially_evaluated": + status += f' · {data["assignments_completed"] or 0}/{required}' + values = [ + number, + data["name"], + person_name(link.project.leader), + link.project.leader.email, + data["case_name"] or "Без выбранного кейса", + ("Сдано" if data["submitted"] else "Не сдано") + if program.is_competitive + else "Не требуется", + "Черновик заявки" if data["draft"] else "Заявка завершена", + status, + data["presentation_url"] or "Не добавлена", + ] + sheet.append([_literal_excel_cell(sheet, value) for value in values]) + sheet.auto_filter.ref = f"A1:I{number + 1}" + stream = BytesIO() + workbook.save(stream) + return stream.getvalue() diff --git a/partner_programs/services/results.py b/partner_programs/services/results.py index 45916943..e1ba7402 100644 --- a/partner_programs/services/results.py +++ b/partner_programs/services/results.py @@ -46,19 +46,20 @@ def valid_score(criterion, value): return False -def results_data(program, query): +def results_data(program, query, *, project_link_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] field = get_program_case_field(program) options = field.get_options_list() if field else [] if query["case_scope"] == "selected" and query.get("case_name") not in options: raise serializers.ValidationError({"case_name": "Выберите существующий кейс."}) - links = list( - project_case_links(program, field=field) - .filter(submitted=True) - .select_related("project") - .order_by("pk") + links_query = project_case_links(program, field=field) + links_query = ( + links_query.filter(submitted=True) + if project_link_id is None + else links_query.filter(pk=project_link_id) ) + links = list(links_query.select_related("project").order_by("pk")) project_ids = [link.project_id for link in links] values, people = defaultdict(dict), defaultdict(dict) for score in ProjectScore.objects.filter( @@ -130,7 +131,9 @@ def results_data(program, query): "case_name": link.case_name, "completed": len(completed), "required": required, - "ready": len(completed) >= required and score is not None, + "ready": link.submitted + and len(completed) >= required + and score is not None, "score": numeric(score), "place": None, "averages": averages, @@ -144,7 +147,8 @@ def results_data(program, query): key=lambda p: (-p["score"], p["name"].lower(), p["id"]), ) last_score, place = None, 0 - for index, project in enumerate(ready, 1): + # A single-project projection cannot determine a place among its peers. + for index, project in enumerate(ready if project_link_id is None else [], 1): if project["score"] != last_score: place = index project["place"] = place diff --git a/partner_programs/tests/test_management_projects_export.py b/partner_programs/tests/test_management_projects_export.py new file mode 100644 index 00000000..31a5708c --- /dev/null +++ b/partner_programs/tests/test_management_projects_export.py @@ -0,0 +1,140 @@ +from io import BytesIO + +from django.test import TestCase +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_project, + create_project, + create_user, +) + + +class ManagementProjectsExportTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.program = create_partner_program(is_competitive=True) + cls.manager = create_user() + cls.program.managers.add(cls.manager) + cls.field = PartnerProgramField.objects.create( + partner_program=cls.program, + name="case", + label="Кейс", + field_type="select", + options="Банк|Город", + is_required=True, + show_filter=True, + ) + cls.links = [] + for i in range(25): + project = create_project(name=f"Проект {i:02d}", draft=i == 0) + link = create_program_project(cls.program, project=project, submitted=False) + PartnerProgramFieldValue.objects.create( + program_project=link, + field=cls.field, + value_text="Банк" if i < 23 else "Город", + ) + type(link).objects.filter(pk=link.pk).update(submitted=i < 22) + cls.links.append(link) + create_program_project( + create_partner_program(), project=create_project(name="Чужой") + ) + cls.root = f"/programs/{cls.program.pk}/management/projects/" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def test_export_matches_all_filtered_rows_before_pagination(self): + query = { + "search": "Проект", + "case_scope": "selected", + "case_name": "Банк", + "submission": "submitted", + "evaluation": "not_evaluated", + "limit": 1, + "offset": 20, + } + page = self.client.get(self.root, query) + self.assertEqual(page.status_code, 200, page.data) + self.assertEqual(page.data["count"], 22) + response = self.client.get(self.root + "export/", query) + self.assertEqual(response.status_code, 200) + self.assertIn("no-store", response["Cache-Control"]) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.max_row, 23) + self.assertEqual( + [sheet.cell(i, 1).value for i in range(2, 24)], list(range(1, 23)) + ) + self.assertEqual(sheet.cell(2, 7).value, "Черновик заявки") + self.assertEqual(sheet.cell(2, 6).value, "Сдано") + self.assertEqual(sheet.cell(2, 8).value, "Не оценено") + + def test_literals_empty_and_validation(self): + project = self.links[0].project + project.name = '=HYPERLINK("https://example.test")' + project.save(update_fields=["name"]) + response = self.client.get(self.root + "export/", {"search": "HYPERLINK"}) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.max_row, 2) + self.assertEqual(sheet.cell(2, 2).value, project.name) + self.assertEqual(sheet.cell(2, 2).data_type, "s") + empty = self.client.get(self.root + "export/", {"search": "Не существует"}) + self.assertEqual(load_workbook(BytesIO(empty.content)).active.max_row, 1) + for query in [ + {"evaluation": "wrong"}, + {"case_scope": "selected", "case_name": "Чужой"}, + ]: + self.assertEqual( + self.client.get(self.root + "export/", query).status_code, 400 + ) + + def test_evaluated_filter_and_noncompetitive_export(self): + from project_rates.models import Criteria, ProjectScore + from project_rates.tests.helpers import create_rate_expert + + criterion = Criteria.objects.create( + partner_program=self.program, + name="Баллы", + type="int", + min_value=0, + max_value=10, + ) + expert = create_rate_expert(program=self.program) + ProjectScore.objects.create( + project=self.links[1].project, user=expert, criteria=criterion, value="7" + ) + query = {"evaluation": "evaluated"} + page = self.client.get(self.root, query) + sheet = load_workbook( + BytesIO(self.client.get(self.root + "export/", query).content) + ).active + self.assertEqual(page.data["count"], 1) + self.assertEqual(sheet.max_row, 2) + self.assertEqual(sheet.cell(2, 2).value, page.data["results"][0]["name"]) + self.assertEqual(sheet.cell(2, 8).value, "Оценено") + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + sheet = load_workbook( + BytesIO(self.client.get(self.root + "export/").content) + ).active + self.assertEqual(sheet.max_row, 26) + self.assertEqual(sheet.cell(2, 6).value, "Не требуется") + self.assertEqual(sheet.cell(2, 8).value, "Не требуется") + + def test_access_scoping(self): + for user, status in [ + (None, 401), + (create_user(), 403), + (create_user(is_staff=True), 200), + ]: + self.client.force_authenticate(user) + self.assertEqual(self.client.get(self.root + "export/").status_code, status) + self.client.force_authenticate(self.manager) + self.assertEqual( + self.client.get("/programs/99999999/management/projects/export/").status_code, + 404, + ) diff --git a/partner_programs/tests/test_management_results_api.py b/partner_programs/tests/test_management_results_api.py index 73a1e06c..866f5a13 100644 --- a/partner_programs/tests/test_management_results_api.py +++ b/partner_programs/tests/test_management_results_api.py @@ -108,6 +108,42 @@ def rows(self, **query): self.assertEqual(response.status_code, 200, response.data) return [p for g in response.data["groups"] for p in g["projects"]] + def test_evaluation_detail_uses_same_canonical_scores_as_results(self): + from partner_programs.models import PartnerProgramProject + + snapshot = self.client.get(self.url()).data + for expected in self.rows(): + response = self.client.get( + f'/programs/{self.program.pk}/management/projects/{expected["id"]}/evaluation/' + ) + self.assertEqual(response.status_code, 200, response.data) + actual = response.data["result"] + self.assertIsNone(actual["place"]) + for key in ( + "project_id", + "name", + "completed", + "required", + "ready", + "score", + "averages", + "experts", + ): + self.assertEqual(actual[key], expected[key], key) + self.assertEqual(response.data["criteria"], snapshot["criteria"]) + self.assertIn("can_rate", response.data) + self.assertIn("experts", response.data) + link = PartnerProgramProject.objects.get( + project=self.projects[-1], partner_program=self.program + ) + response = self.client.get( + f"/programs/{self.program.pk}/management/projects/{link.pk}/evaluation/" + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["submitted"]) + self.assertFalse(response.data["result"]["ready"]) + self.assertIsNone(response.data["result"]["place"]) + def test_rounding_ties_case_ranks_and_pending(self): rows = {p["name"]: p for p in self.rows()} self.assertEqual((rows["Город"]["score"], rows["Город"]["place"]), (24, 1)) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index a2a724e3..f2e00763 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -57,6 +57,7 @@ ManagementParticipantsRemovalPreviewView, ManagementParticipantsRemovalView, ManagementProjectsView, + ManagementProjectsExportView, ManagementTeamView, ManagementEvaluationView, ManagementSettingsView, @@ -184,6 +185,11 @@ ManagementProjectsView.as_view(), name="management-projects", ), + path( + "/management/projects/export/", + ManagementProjectsExportView.as_view(), + name="management-projects-export", + ), path( "/management/projects//team/", ManagementTeamView.as_view(),