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
49 changes: 45 additions & 4 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Панель организатора поверх существующей модели программ."""

from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import serializers, status
Expand All @@ -11,7 +12,7 @@
from partner_programs.models import PartnerProgramMaterial
from partner_programs.pagination import ProgramAttentionPagination
from partner_programs.serializers.management import (
ManagementListQuery,
ManagementParticipantQuery,
ManagementProjectQuery,
ManagementSettingsSerializer,
ManagementMaterialWrite,
Expand All @@ -38,6 +39,11 @@ def conflict(code, detail):
class ManagementBase(APIView):
permission_classes = [IsAuthenticated]

def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
response["Cache-Control"] = "private, no-store"
return response


class ManagementView(ManagementBase):
def get(self, request, pk):
Expand Down Expand Up @@ -71,13 +77,48 @@ def put(self, request, pk):
class ManagementParticipantsView(ManagementBase):
def get(self, request, pk):
program = managed_program(pk, request.user)
query = ManagementListQuery(data=request.query_params)
query = ManagementParticipantQuery(data=request.query_params)
query.is_valid(raise_exception=True)
paginator = ProgramAttentionPagination(query.validated_data)
page = paginator.paginate_queryset(
participant_rows(program, query.validated_data["search"]), request, view=self
participant_rows(
program, query.validated_data["search"], query.validated_data
),
request,
view=self,
)
response = paginator.get_paginated_response([participant_data(p) for p in page])
all_rows = participant_rows(program)
response.data.update(
total_count=all_rows.count(),
region_options=list(
all_rows.exclude(participant_region="")
.order_by("participant_region")
.values_list("participant_region", flat=True)
.distinct()
),
)
return response


class ManagementParticipantsExportView(ManagementBase):
def get(self, request, pk):
from partner_programs.services.participants_excel import export_participants

program = managed_program(pk, request.user)
query = ManagementParticipantQuery(data=request.query_params)
query.is_valid(raise_exception=True)
rows = participant_rows(
program, query.validated_data["search"], query.validated_data
)
return paginator.get_paginated_response([participant_data(p) for p in page])
response = HttpResponse(
export_participants(rows),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response[
"Content-Disposition"
] = f'attachment; filename="program-{pk}-participants.xlsx"'
return response


class ManagementProjectsView(ManagementBase):
Expand Down
33 changes: 33 additions & 0 deletions partner_programs/serializers/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ class ManagementListQuery(StrictFieldsMixin, serializers.Serializer):
offset = serializers.IntegerField(min_value=0, default=0)


class ManagementParticipantQuery(ManagementListQuery):
region_scope = serializers.ChoiceField(
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_from = serializers.DateField(required=False)
activity_to = serializers.DateField(required=False)

def validate(self, attrs):
if attrs["region_scope"] == "selected" and not attrs.get("region"):
raise serializers.ValidationError({"region": "Выберите регион."})
if attrs["region_scope"] != "selected" and attrs.get("region"):
raise serializers.ValidationError(
{"region": "Регион задан без selected scope."}
)
start, end = attrs.get("activity_from"), attrs.get("activity_to")
if attrs["activity"] == "period":
if not (start or end):
raise serializers.ValidationError(
{"activity_from": "Укажите начало или конец периода."}
)
if start and end and start > end:
raise serializers.ValidationError(
{"activity_to": "Конец периода раньше начала."}
)
elif start or end:
raise serializers.ValidationError(
{"activity": "Даты доступны только для периода."}
)
return attrs


class ManagementProjectQuery(ManagementListQuery):
submission = serializers.ChoiceField(
choices=("all", "submitted", "not_submitted"), default="all"
Expand Down
35 changes: 33 additions & 2 deletions partner_programs/services/management.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Program-scoped чтение для организатора без изменений участников и проектов."""

from django.db.models import Q
from datetime import datetime, time
from zoneinfo import ZoneInfo

from django.db.models import 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 @@ -73,12 +77,20 @@ def management_data(program):
}


def participant_rows(program, search):
def participant_rows(program, search="", query=None):
rows = (
PartnerProgramUserProfile.objects.filter(
partner_program=program, user__isnull=False
)
.select_related("user")
# The profile's region picker writes to the historical `city` field.
.annotate(
participant_region=Coalesce(
NullIf(Trim("user__city"), Value("")),
NullIf(Trim("user__region"), Value("")),
Value(""),
)
)
.order_by("datetime_created", "pk")
)
if search:
Expand All @@ -90,6 +102,24 @@ def participant_rows(program, search):
| Q(user__patronymic__icontains=part)
| Q(user__email__icontains=part)
)
if query:
if query["region_scope"] == "selected":
rows = rows.filter(participant_region=query["region"])
elif query["region_scope"] == "without_region":
rows = rows.filter(participant_region="")
if query["activity"] == "never":
rows = rows.filter(user__last_activity__isnull=True)
elif query["activity"] == "period":
zone = ZoneInfo("Europe/Moscow")
if query.get("activity_from"):
rows = rows.filter(
user__last_activity__gte=datetime.combine(
query["activity_from"], time.min, zone
)
)
if query.get("activity_to"):
end = datetime.combine(query["activity_to"], time.max, zone)
rows = rows.filter(user__last_activity__lte=end)
return rows


Expand All @@ -100,6 +130,7 @@ def participant_data(profile):
"name": person_name(profile.user),
"email": profile.user.email,
"city": profile.user.city,
"region": profile.participant_region or None,
"registered_at": profile.datetime_created,
"last_activity": profile.user.last_activity,
}
Expand Down
66 changes: 66 additions & 0 deletions partner_programs/services/participants_excel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Export the same filtered queryset as the organizer's participant list."""

from io import BytesIO
from zoneinfo import ZoneInfo

from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter

from partner_programs.services.management import person_name


def export_participants(rows):
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet("Участники")
sheet.freeze_panes = "C2"
headers = [
"№",
"Участник",
"Почта",
"Регион",
"В программе с (МСК)",
"Последняя активность (МСК)",
]
for index, width in enumerate((6, 32, 38, 28, 24, 28), 1):
sheet.column_dimensions[get_column_letter(index)].width = width
header_cells = []
for title in headers:
cell = WriteOnlyCell(sheet, title)
cell.font = Font(bold=True)
cell.fill = PatternFill("solid", fgColor="EEE8FB")
header_cells.append(cell)
sheet.append(header_cells)
number = 0
for number, profile in enumerate(rows.iterator(chunk_size=500), 1):
values = [
number,
person_name(profile.user),
profile.user.email,
profile.participant_region,
profile.datetime_created,
profile.user.last_activity,
]
cells = []
for value in values:
if hasattr(value, "tzinfo"):
cell = WriteOnlyCell(
sheet,
value.astimezone(ZoneInfo("Europe/Moscow")).replace(tzinfo=None),
)
cell.number_format = "dd.mm.yyyy hh:mm"
elif isinstance(value, int):
cell = WriteOnlyCell(sheet, value)
else:
cell = WriteOnlyCell(
sheet, ILLEGAL_CHARACTERS_RE.sub("", str(value or "—"))
)
cell.data_type = "s"
cells.append(cell)
sheet.append(cells)
sheet.auto_filter.ref = f"A1:F{number + 1}"
stream = BytesIO()
workbook.save(stream)
return stream.getvalue()
142 changes: 142 additions & 0 deletions partner_programs/tests/test_management_participants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from datetime import datetime
from io import BytesIO

from django.test import TestCase
from openpyxl import load_workbook
from rest_framework.test import APIClient

from partner_programs.tests.helpers import (
create_partner_program,
create_program_member,
create_user,
)


class ManagementParticipantsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.manager = create_user()
cls.program = create_partner_program()
cls.program.managers.add(cls.manager)
cls.rows = []
for name, region, activity in [
("Анна", " Москва ", "2026-10-18T21:00:00+00:00"),
("Анна", "Москва", "2026-10-19T20:59:59+00:00"),
("Анна", "Москва", "2026-10-19T21:00:00+00:00"),
("Анна", "Казань", "2026-10-19T12:00:00+00:00"),
("Иван", " ", None),
("Иван", None, None),
]:
user = create_user(first_name=name, region=region)
user.__class__.objects.filter(pk=user.pk).update(
last_activity=datetime.fromisoformat(activity) if activity else None
)
cls.rows.append(create_program_member(cls.program, user=user))
create_program_member(
create_partner_program(), user=create_user(region="Чужой регион")
)
cls.root = f"/programs/{cls.program.pk}/management/participants/"

def setUp(self):
self.client = APIClient()
self.client.force_authenticate(self.manager)

def test_intersecting_filters_use_full_inclusive_moscow_days(self):
response = self.client.get(
self.root,
{
"search": "Анна",
"region_scope": "selected",
"region": "Москва",
"activity": "period",
"activity_from": "2026-10-19",
"activity_to": "2026-10-19",
"limit": 1,
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 2)
self.assertEqual(response.data["total_count"], 6)
self.assertEqual(response.data["results"][0]["region"], "Москва")
self.assertEqual(response.data["region_options"], ["Казань", "Москва"])
second = self.client.get(
self.root, {"activity": "period", "activity_to": "2026-10-18"}
)
self.assertEqual(second.data["count"], 0)

def test_missing_activity_and_region_are_explicit_filters(self):
response = self.client.get(
self.root, {"region_scope": "without_region", "activity": "never"}
)
self.assertEqual(response.data["count"], 2)
self.assertTrue(
all(
row["region"] is None and row["last_activity"] is None
for row in response.data["results"]
)
)

def test_invalid_filters_fail_for_list_and_export(self):
for query in [
{"activity": "period"},
{"activity": "period", "activity_from": "broken"},
{
"activity": "period",
"activity_from": "2026-10-20",
"activity_to": "2026-10-19",
},
{"activity": "never", "activity_from": "2026-10-19"},
{"region_scope": "selected"},
{"region_scope": "wrong"},
{"region": "Москва"},
]:
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_export_uses_all_filtered_rows_ignoring_pagination_and_keeps_typed_dates(
self,
):
response = self.client.get(
self.root + "export/",
{"region_scope": "selected", "region": "Москва", "limit": 1, "offset": 1},
)
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, 4)
self.assertEqual([sheet.cell(row, 1).value for row in range(2, 5)], [1, 2, 3])
self.assertEqual(sheet.cell(2, 6).value, datetime(2026, 10, 19))
self.assertEqual(sheet.cell(2, 6).data_type, "d")

def test_region_matches_profile_picker_city_with_legacy_region_fallback(self):
user = self.rows[0].user
user.city = " Татарстан "
user.region = "Устаревшее значение"
user.save(update_fields=["city", "region"])
response = self.client.get(
self.root, {"region_scope": "selected", "region": "Татарстан"}
)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["region"], "Татарстан")
self.assertNotIn("Устаревшее значение", response.data["region_options"])

def test_export_neutralizes_user_formulas(self):
user = self.rows[0].user
user.first_name = '=HYPERLINK("bad")'
user.save(update_fields=["first_name"])
sheet = load_workbook(
BytesIO(self.client.get(self.root + "export/").content)
).active
self.assertTrue(sheet.cell(2, 2).value.startswith("="))
self.assertEqual(sheet.cell(2, 2).data_type, "s")

def test_export_permission_and_empty_result(self):
empty = self.client.get(self.root + "export/", {"search": "Не существует"})
self.assertEqual(load_workbook(BytesIO(empty.content)).active.max_row, 1)
for user, status in [(create_user(), 403), (None, 401)]:
self.client.force_authenticate(user)
for suffix in ("", "export/"):
self.assertEqual(self.client.get(self.root + suffix).status_code, status)
Loading
Loading