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
11 changes: 10 additions & 1 deletion docs/modules/program-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` независимо от сдачи.

Expand Down Expand Up @@ -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`). Автоматическая публикация и изменение пользовательских данных не выполняются. Фильтры проектов применяются вместе из модального окна; выгрузка и выбор всех используют примененные фильтры.
23 changes: 23 additions & 0 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions partner_programs/services/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
),
}
66 changes: 66 additions & 0 deletions partner_programs/services/management_projects_excel.py
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 12 additions & 8 deletions partner_programs/services/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
140 changes: 140 additions & 0 deletions partner_programs/tests/test_management_projects_export.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading