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
4 changes: 3 additions & 1 deletion docs/modules/program-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@

Чтение и запись доступны только manager **этой** программы либо staff/superuser по существующему `can_manage_program`. Expert без роли manager не получает панель. Чужая программа даёт 403, отсутствующая — 404; чужая связь или материал внутри доступной программы — 404. Mutations участников/проектов отсутствуют.

Списки принимают `search`, `limit` (1–100, по умолчанию25), `offset` (от0). Поиск участников — AND по фрагментам, OR между ФИО и почтой; удалённые пользователи не считаются действующими участниками. Порядок — дата регистрации/связи и PK. Проекты дополнительно принимают `submission=all|submitted|not_submitted`, `case_scope=all|selected|without_case`, при selected — точный `case_name`. Неправильные параметры возвращают400, выдача ограничена страницей.
Списки принимают `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/` применяют те же фильтры и порядок ко всем совпадениям, игнорируя пагинацию. Права организатора и формат ответа не меняются; миграций нет.

Оценивание повторно использует `_solution_rows`: в distributed учитываются завершённые реальные назначения; частичная оценка сохраняется отдельно. В open сохраняется существующая семантика наличия оценки по критериям программы. Несданное решение имеет приоритет, неконкурсное — `not_applicable`. Статусы вычисляет сервер. Презентация берётся из `Project.presentation_address` независимо от сдачи.

Expand Down
7 changes: 6 additions & 1 deletion partner_programs/serializers/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ class ManagementParticipantQuery(ManagementListQuery):
choices=("all", "selected", "without_region"), default="all"
)
region = serializers.CharField(required=False, max_length=255)
activity = serializers.ChoiceField(choices=("all", "period", "never"), default="all")
activity = serializers.ChoiceField(
choices=("all", "period", "active", "inactive", "never"), default="all"
)
order = serializers.ChoiceField(
choices=("registration", "activity_asc", "activity_desc"), default="registration"
)
activity_from = serializers.DateField(required=False)
activity_to = serializers.DateField(required=False)

Expand Down
15 changes: 13 additions & 2 deletions partner_programs/services/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime, time
from zoneinfo import ZoneInfo

from django.db.models import Q, Value
from django.db.models import F, Q, Value
from django.db.models.functions import Coalesce, NullIf, Trim
from django.shortcuts import get_object_or_404
from rest_framework.exceptions import PermissionDenied
Expand Down Expand Up @@ -108,8 +108,10 @@ def participant_rows(program, search="", query=None):
rows = rows.filter(participant_region=query["region"])
elif query["region_scope"] == "without_region":
rows = rows.filter(participant_region="")
if query["activity"] == "never":
if query["activity"] in ("inactive", "never"):
rows = rows.filter(user__last_activity__isnull=True)
elif query["activity"] == "active":
rows = rows.filter(user__last_activity__isnull=False)
elif query["activity"] == "period":
zone = ZoneInfo("Europe/Moscow")
if query.get("activity_from"):
Expand All @@ -121,6 +123,15 @@ def participant_rows(program, search="", query=None):
if query.get("activity_to"):
end = datetime.combine(query["activity_to"], time.max, zone)
rows = rows.filter(user__last_activity__lte=end)
order = query.get("order", "registration")
if order in ("activity_asc", "activity_desc"):
activity = F("user__last_activity")
rows = rows.order_by(
activity.asc(nulls_last=True)
if order == "activity_asc"
else activity.desc(nulls_last=True),
"pk",
)
return rows


Expand Down
62 changes: 62 additions & 0 deletions partner_programs/tests/test_management_participants.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,75 @@ def test_invalid_filters_fail_for_list_and_export(self):
{"region_scope": "selected"},
{"region_scope": "wrong"},
{"region": "Москва"},
{"activity": "active", "activity_to": "2026-10-19"},
{"activity": "wrong"},
{"order": "user__email"},
]:
for suffix in ("", "export/"):
with self.subTest(query=query, suffix=suffix):
self.assertEqual(
self.client.get(self.root + suffix, query).status_code, 400
)

def test_activity_presence_filters_match_selection_and_export(self):
selection = self.root + "selection/"
for mode, expected in [
("active", self.rows[:4]),
("inactive", self.rows[4:]),
("never", self.rows[4:]),
]:
with self.subTest(mode=mode):
query = {"activity": mode, "limit": 1, "offset": 1}
response = self.client.get(self.root, query)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], len(expected))
selected = self.client.get(selection, query)
self.assertEqual(selected.status_code, 200)
self.assertEqual(selected.data["ids"], [p.pk for p in expected])
sheet = load_workbook(
BytesIO(self.client.get(self.root + "export/", query).content)
).active
self.assertEqual(sheet.max_row, len(expected) + 1)
for row in range(2, sheet.max_row + 1):
self.assertEqual(
isinstance(sheet.cell(row, 6).value, datetime), mode == "active"
)

def test_activity_sort_is_global_stable_and_nulls_always_last(self):
# Одинаковые даты сортируются по id, отсутствие активности идет в конце.
user = self.rows[3].user
user.last_activity = datetime.fromisoformat("2026-10-19T20:59:59+00:00")
user.save(update_fields=["last_activity"])
for order, indices in [
("activity_asc", [0, 1, 3, 2, 4, 5]),
("activity_desc", [2, 1, 3, 0, 4, 5]),
]:
with self.subTest(order=order):
expected = [self.rows[i].pk for i in indices]
full = self.client.get(self.root, {"order": order})
self.assertEqual([p["id"] for p in full.data["results"]], expected)
page = self.client.get(
self.root, {"order": order, "limit": 2, "offset": 2}
)
self.assertEqual([p["id"] for p in page.data["results"]], expected[2:4])
filtered = self.client.get(
self.root,
{"order": order, "region_scope": "selected", "region": "Москва"},
)
self.assertEqual(
[p["id"] for p in filtered.data["results"]],
[pk for pk in expected if pk in [p.pk for p in self.rows[:3]]],
)
sheet = load_workbook(
BytesIO(
self.client.get(self.root + "export/", {"order": order}).content
)
).active
self.assertEqual(
[sheet.cell(i, 3).value for i in range(2, 8)],
[self.rows[i].user.email for i in indices],
)

def test_export_uses_all_filtered_rows_ignoring_pagination_and_keeps_typed_dates(
self,
):
Expand Down
Loading