diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index 931d9201..1216430f 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -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` независимо от сдачи. diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index bec9c4b6..44f86b8a 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -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) diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 67532c5c..1254b71f 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -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 @@ -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"): @@ -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 diff --git a/partner_programs/tests/test_management_participants.py b/partner_programs/tests/test_management_participants.py index b41c06dc..a9094ce4 100644 --- a/partner_programs/tests/test_management_participants.py +++ b/partner_programs/tests/test_management_participants.py @@ -89,6 +89,9 @@ 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): @@ -96,6 +99,65 @@ def test_invalid_filters_fail_for_list_and_export(self): 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, ):