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
92 changes: 92 additions & 0 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
ManagementProjectQuery,
ManagementSettingsSerializer,
ManagementMaterialWrite,
ManagementIdsWrite,
ManagementRemovalWrite,
)
from partner_programs.services.case_fields import get_program_case_field
from partner_programs.services.case_analytics import project_case_links
Expand Down Expand Up @@ -103,6 +105,27 @@ def get(self, request, pk):


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

program = managed_program(pk, request.user)
body = ManagementIdsWrite(data=request.data)
body.is_valid(raise_exception=True)
ids = body.validated_data["ids"]
rows = participant_rows(program).filter(pk__in=ids)
if rows.count() != len(ids):
return conflict(
"selection_changed", "Состав участников изменился. Обновите выбор."
)
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

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

Expand All @@ -122,6 +145,75 @@ def get(self, request, pk):
return response


class ManagementSelectionView(ManagementBase):
def get(self, request, pk, kind):
program = managed_program(pk, request.user)
field = get_program_case_field(program)
schema = (
ManagementParticipantQuery
if kind == "participants"
else ManagementProjectQuery
)
query = schema(
data=request.query_params,
context={"case_options": field.get_options_list() if field else []},
)
query.is_valid(raise_exception=True)
if kind == "participants":
ids = list(
participant_rows(
program, query.validated_data["search"], query.validated_data
).values_list("pk", flat=True)
)
return Response({"ids": ids})
rows = management_project_rows(program, query.validated_data, field)
projects = list(
rows.values("id", "project_id", "submitted", "case_name", "project__draft")
)
return Response(
{
"ids": [p["id"] for p in projects],
"projects": [
{
"id": p["id"],
"project_id": p["project_id"],
"submitted": p["submitted"],
"case_name": p["case_name"],
"draft": p["project__draft"],
}
for p in projects
],
}
)


class ManagementParticipantsRemovalPreviewView(ManagementBase):
def post(self, request, pk):
from partner_programs.services.participant_removal import preview_removal

managed_program(pk, request.user)
body = ManagementIdsWrite(data=request.data)
body.is_valid(raise_exception=True)
return Response(preview_removal(pk, request.user, body.validated_data["ids"]))


class ManagementParticipantsRemovalView(ManagementBase):
def post(self, request, pk):
from partner_programs.services.participant_removal import remove_participants

managed_program(pk, request.user)
body = ManagementRemovalWrite(data=request.data)
body.is_valid(raise_exception=True)
return Response(
remove_participants(
pk,
request.user,
body.validated_data["ids"],
body.validated_data["preview_token"],
)
)


class ManagementProjectsView(ManagementBase):
def get(self, request, pk):
program = managed_program(pk, request.user)
Expand Down
18 changes: 17 additions & 1 deletion partner_programs/serializers/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class ManagementListQuery(StrictFieldsMixin, serializers.Serializer):
search = serializers.CharField(
required=False, allow_blank=True, max_length=200, default=""
)
limit = serializers.IntegerField(min_value=1, max_value=100, default=25)
limit = serializers.IntegerField(min_value=1, max_value=100, default=20)
offset = serializers.IntegerField(min_value=0, default=0)


Expand Down Expand Up @@ -102,6 +102,9 @@ def validate(self, attrs):


class ManagementProjectQuery(ManagementListQuery):
evaluation = serializers.ChoiceField(
choices=("all", "evaluated", "not_evaluated"), default="all"
)
submission = serializers.ChoiceField(
choices=("all", "submitted", "not_submitted"), default="all"
)
Expand All @@ -123,6 +126,19 @@ def validate(self, attrs):
return attrs


class ManagementIdsWrite(StrictFieldsMixin, serializers.Serializer):
ids = serializers.ListField(
child=serializers.IntegerField(min_value=1), allow_empty=False, max_length=10000
)

def validate_ids(self, values):
return sorted(set(values))


class ManagementRemovalWrite(ManagementIdsWrite):
preview_token = serializers.CharField(max_length=200000)


BASIC_FIELDS = (
"name",
"city",
Expand Down
5 changes: 5 additions & 0 deletions partner_programs/services/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ def management_project_rows(program, query, field):
rows = rows.filter(project__name__icontains=query["search"])
if query["submission"] != "all":
rows = rows.filter(submitted=query["submission"] == "submitted")
if query.get("evaluation", "all") == "evaluated":
rows = rows.filter(status="evaluated")
elif query.get("evaluation") == "not_evaluated":
rows = rows.exclude(status="evaluated")
if query["case_scope"] == "selected":
rows = rows.filter(case_name=query["case_name"])
elif query["case_scope"] == "without_case":
Expand All @@ -170,6 +174,7 @@ def project_data(link, program):
"name": person_name(link.project.leader),
},
"submitted": link.submitted,
"draft": link.project.draft,
"evaluation_status": status,
"assignments_total": link.assignments_total,
"assignments_completed": link.assignments_completed,
Expand Down
181 changes: 181 additions & 0 deletions partner_programs/services/participant_removal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Удаление регистрации организатором с явным подтверждением заявок лидеров."""

import logging

from django.core import signing
from django.db import transaction
from rest_framework.exceptions import APIException

from partner_programs.models import PartnerProgram, PartnerProgramProject
from partner_programs.services.management import managed_program, participant_rows
from project_rates.models import ProjectExpertAssignment, ProjectScore
from projects.models import Project

logger = logging.getLogger(__name__)
SALT = "management-participant-removal-v1"


class RemovalConflict(APIException):
status_code = 409

def __init__(
self,
code="selection_changed",
detail="Состав участников или заявки изменились. Обновите список и повторите удаление.",
):
super().__init__({"code": code, "detail": detail})


def selected_profiles(program, ids, *, lock=False):
rows = participant_rows(program).filter(pk__in=ids).order_by("pk")
if lock:
rows = rows.select_for_update(of=("self",))
profiles = list(rows)
if len(profiles) != len(set(ids)):
raise RemovalConflict()
return profiles


def applications(program, profiles):
return list(
PartnerProgramProject.objects.filter(
partner_program=program, project__leader_id__in=[p.user_id for p in profiles]
)
.select_related("project")
.order_by("pk")
)


def snapshot(program, profiles, links):
projects = [link.project_id for link in links]
return {
"profiles": [[p.pk, p.user_id] for p in profiles],
"applications": [
[
link.pk,
link.project_id,
link.project.leader_id,
link.submitted,
link.datetime_updated.isoformat(),
]
for link in links
],
"assignments": list(
ProjectExpertAssignment.objects.filter(
partner_program=program, project_id__in=projects
)
.order_by("pk")
.values_list("pk", "project_id", "expert_id")
),
"scores": list(
ProjectScore.objects.filter(
criteria__partner_program=program, project_id__in=projects
)
.order_by("pk")
.values_list("pk", "project_id", "criteria_id", "user_id", "value")
),
}


def normalized_snapshot(program, profiles, links):
# JSON-signing сохраняет tuples как lists; оба прохода сравнивают одну форму.
state = snapshot(program, profiles, links)
state["assignments"] = [list(row) for row in state["assignments"]]
state["scores"] = [list(row) for row in state["scores"]]
return state


def preview_removal(program_id, actor, ids):
program = managed_program(program_id, actor)
profiles = selected_profiles(program, ids)
links = applications(program, profiles)
state = normalized_snapshot(program, profiles, links)
token = signing.dumps(
{
"program": program.pk,
"actor": actor.pk,
"ids": sorted(set(ids)),
"state": state,
},
salt=SALT,
compress=True,
)
return {
"preview_token": token,
"participant_count": len(profiles),
"applications": [
{
"id": link.pk,
"project_id": link.project_id,
"name": link.project.name,
"submitted": link.submitted,
}
for link in links
],
"assignments_count": len(state["assignments"]),
}


@transaction.atomic
def remove_participants(program_id, actor, ids, token):
program = managed_program(program_id, actor)
try:
proof = signing.loads(token, salt=SALT, max_age=900)
except signing.BadSignature:
raise RemovalConflict(
"preview_expired", "Подтверждение устарело. Откройте удаление еще раз."
)
ids = sorted(set(ids))
if (
proof.get("program") != program.pk
or proof.get("actor") != actor.pk
or proof.get("ids") != ids
):
raise RemovalConflict()
profiles = selected_profiles(program, ids)
user_ids = [p.user_id for p in profiles]
project_ids = set(
PartnerProgramProject.objects.filter(
partner_program=program, project__leader_id__in=user_ids
).values_list("project_id", flat=True)
)
# Тот же порядок, что у lock_team: сначала Project, затем программы и профили.
# Все проекты блокируются до программ, чтобы массовая операция не меняла порядок.
list(Project.objects.select_for_update().filter(pk__in=project_ids).order_by("pk"))
program_ids = set(
PartnerProgramProject.objects.filter(project_id__in=project_ids).values_list(
"partner_program_id", flat=True
)
) | {program.pk}
list(
PartnerProgram.objects.select_for_update()
.filter(pk__in=program_ids)
.order_by("pk")
)
program = managed_program(program_id, actor)
profiles = selected_profiles(program, ids, lock=True)
links = applications(program, profiles)
if {link.project_id for link in links} != project_ids or normalized_snapshot(
program, profiles, links
) != proof.get("state"):
raise RemovalConflict()
link_ids = [link.pk for link in links]
ProjectExpertAssignment.objects.filter(
partner_program=program, project_id__in=project_ids
).delete()
# Отдельная команда организатора с подписанным preview. Обычный model.delete
# по-прежнему запрещает участнику удалять сданную заявку. Здесь удаляется
# только program link и его поля; Project, Collaborator и ProjectScore целы.
PartnerProgramProject.objects.filter(
partner_program=program, pk__in=link_ids
).delete()
for profile in profiles:
profile.delete()
logger.info(
"Program registration removal program=%s actor=%s profiles=%s applications=%s",
program.pk,
actor.pk,
ids,
link_ids,
)
return {"removed": len(profiles), "removed_applications": len(link_ids)}
2 changes: 1 addition & 1 deletion partner_programs/services/participants_excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def export_participants(rows):
"Участник",
"Почта",
"Регион",
"В программе с (МСК)",
"Дата регистрации (МСК)",
"Последняя активность (МСК)",
]
for index, width in enumerate((6, 32, 38, 28, 24, 28), 1):
Expand Down
Loading
Loading