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
98 changes: 98 additions & 0 deletions docs/manager-program-overview-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!-- Roadmap: DEV-076, DEV-056 -->

# Manager program overview API

DEV-076 является первым read-only этапом DEV-056 «Кабинет менеджера». Endpoint
возвращает компактные обезличенные счетчики по пути участника от регистрации до
экспертной оценки.

## Endpoint и права

```http
GET /programs/<program_id>/manager-overview/
```

Требуется авторизация. Доступ разрешен manager указанной `PartnerProgram`, staff
и superuser. Обычный участник и manager другой программы получают `403`.
Несуществующая программа возвращает `404`.

## Контракт

```json
{
"program": {"id": 1, "name": "Program"},
"registrations": {"total": 0},
"applications": {
"total": 0,
"by_status": {
"draft": 0,
"submitted": 0,
"approved": 0,
"rejected": 0,
"withdrawn": 0,
"cancelled": 0
},
"by_participation_mode": {
"undecided": 0,
"individual": 0,
"team": 0
}
},
"teams": {"total": 0, "accepted_members": 0},
"submissions": {
"total": 0,
"by_status": {
"draft": 0,
"submitted": 0,
"returned": 0,
"final": 0,
"cancelled": 0
},
"applications_with_submitted_solution": 0
},
"expert_assignments": {
"total": 0,
"by_status": {"assigned": 0, "completed": 0, "revoked": 0}
},
"evaluations": {
"total": 0,
"by_status": {"draft": 0, "submitted": 0}
}
}
```

Отсутствующие статусы всегда присутствуют в ответе с нулевым значением.

## Семантика счетчиков

- `registrations.total` — количество `PartnerProgramUserProfile` программы.
- `applications.total` и `by_status` — заявки программы целиком и по статусам.
- `applications.by_participation_mode` — заявки по режимам `undecided`,
`individual` и `team`.
- `teams.total` — команды, связанные с заявками программы.
- `teams.accepted_members` — только `TeamMember` со статусом `accepted`, включая
капитана. Исторические и ожидающие статусы не учитываются.
- `submissions.total` и `by_status` — все версии `Submission` программы.
- `applications_with_submitted_solution` — число уникальных заявок, имеющих хотя
бы одну версию `Submission` в статусе `submitted` или `final`.
- `expert_assignments` — назначения через `Submission` программы целиком и по
статусам `assigned`, `completed`, `revoked`.
- `evaluations` — оценки через `Submission` программы целиком и по статусам
`draft`, `submitted`.

Подсчеты выполняются фиксированным набором ORM aggregate-запросов и не зависят
от количества участников.

## Ограничения данных

Ответ не содержит email, ФИО, `form_data`, `partner_program_data`, ссылки на
пользовательские файлы, содержимое решений, комментарии экспертов и scores
отдельных пользователей.

Списки и фильтры сущностей, просмотр анкет, выгрузки Excel/CSV, результаты,
рейтинг, уведомления, продуктовая аналитика и frontend в DEV-076 не входят.
Endpoint не изменяет заявки, решения, назначения или оценки.

Roadmap-Complete: DEV-076

Roadmap-Partial: DEV-056
37 changes: 37 additions & 0 deletions partner_programs/manager_overview_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Roadmap: DEV-076, DEV-056

from drf_yasg.utils import swagger_auto_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from partner_programs.permissions import IsAdminOrManagerOfProgram
from partner_programs.serializers.manager_overview import (
ManagerProgramOverviewSerializer,
)
from partner_programs.services.manager_overview import (
build_manager_program_overview,
)
from partner_programs.submission_assignment_views import ProgramPermissionMixin


class ManagerProgramOverviewView(ProgramPermissionMixin, APIView):
permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram]

@swagger_auto_schema(
operation_description=(
"Обезличенная read-only сводка этапов участия в программе для manager "
"этой программы и staff."
),
responses={
200: ManagerProgramOverviewSerializer,
401: "Требуется авторизация.",
403: "Нет прав manager этой программы.",
404: "Программа не найдена.",
},
)
def get(self, request, program_id):
overview = build_manager_program_overview(self.program)
serializer = ManagerProgramOverviewSerializer(data=overview)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
18 changes: 10 additions & 8 deletions partner_programs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ def has_permission(self, request, view):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True

program_id = view.kwargs.get("pk") or view.kwargs.get("program_id")
if not program_id:
return False

try:
program = PartnerProgram.objects.get(pk=program_id)
except PartnerProgram.DoesNotExist:
return False
program = getattr(view, "program", None)
if program is None:
program_id = view.kwargs.get("pk") or view.kwargs.get("program_id")
if not program_id:
return False

try:
program = PartnerProgram.objects.get(pk=program_id)
except PartnerProgram.DoesNotExist:
return False

return program.is_manager(user)
78 changes: 78 additions & 0 deletions partner_programs/serializers/manager_overview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Roadmap: DEV-076, DEV-056

from rest_framework import serializers


class ProgramOverviewProgramSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()


class ProgramOverviewTotalSerializer(serializers.Serializer):
total = serializers.IntegerField(min_value=0)


class ApplicationStatusCountsSerializer(serializers.Serializer):
draft = serializers.IntegerField(min_value=0)
submitted = serializers.IntegerField(min_value=0)
approved = serializers.IntegerField(min_value=0)
rejected = serializers.IntegerField(min_value=0)
withdrawn = serializers.IntegerField(min_value=0)
cancelled = serializers.IntegerField(min_value=0)


class ParticipationModeCountsSerializer(serializers.Serializer):
undecided = serializers.IntegerField(min_value=0)
individual = serializers.IntegerField(min_value=0)
team = serializers.IntegerField(min_value=0)


class ProgramOverviewApplicationsSerializer(ProgramOverviewTotalSerializer):
by_status = ApplicationStatusCountsSerializer()
by_participation_mode = ParticipationModeCountsSerializer()


class ProgramOverviewTeamsSerializer(ProgramOverviewTotalSerializer):
accepted_members = serializers.IntegerField(min_value=0)


class SubmissionStatusCountsSerializer(serializers.Serializer):
draft = serializers.IntegerField(min_value=0)
submitted = serializers.IntegerField(min_value=0)
returned = serializers.IntegerField(min_value=0)
final = serializers.IntegerField(min_value=0)
cancelled = serializers.IntegerField(min_value=0)


class ProgramOverviewSubmissionsSerializer(ProgramOverviewTotalSerializer):
by_status = SubmissionStatusCountsSerializer()
applications_with_submitted_solution = serializers.IntegerField(min_value=0)


class AssignmentStatusCountsSerializer(serializers.Serializer):
assigned = serializers.IntegerField(min_value=0)
completed = serializers.IntegerField(min_value=0)
revoked = serializers.IntegerField(min_value=0)


class ProgramOverviewAssignmentsSerializer(ProgramOverviewTotalSerializer):
by_status = AssignmentStatusCountsSerializer()


class EvaluationStatusCountsSerializer(serializers.Serializer):
draft = serializers.IntegerField(min_value=0)
submitted = serializers.IntegerField(min_value=0)


class ProgramOverviewEvaluationsSerializer(ProgramOverviewTotalSerializer):
by_status = EvaluationStatusCountsSerializer()


class ManagerProgramOverviewSerializer(serializers.Serializer):
program = ProgramOverviewProgramSerializer()
registrations = ProgramOverviewTotalSerializer()
applications = ProgramOverviewApplicationsSerializer()
teams = ProgramOverviewTeamsSerializer()
submissions = ProgramOverviewSubmissionsSerializer()
expert_assignments = ProgramOverviewAssignmentsSerializer()
evaluations = ProgramOverviewEvaluationsSerializer()
129 changes: 129 additions & 0 deletions partner_programs/services/manager_overview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Roadmap: DEV-076, DEV-056

from django.db.models import Count, Q

from partner_programs.models import (
Application,
Evaluation,
PartnerProgram,
PartnerProgramUserProfile,
Submission,
SubmissionExpertAssignment,
Team,
TeamMember,
)


def _choice_counts(metrics, prefix, choices):
return {value: metrics[f"{prefix}_{value}"] for value, _label in choices}


def _count_by_choice(choices, field_name):
return {
f"{field_name}_{value}": Count(
"id",
filter=Q(**{field_name: value}),
)
for value, _label in choices
}


def build_manager_program_overview(program: PartnerProgram) -> dict:
"""Возвращает обезличенные агрегаты этапов участия в одной программе."""
registrations = PartnerProgramUserProfile.objects.filter(
partner_program=program
).aggregate(total=Count("id"))

application_metrics = Application.objects.filter(program=program).aggregate(
total=Count("id"),
**_count_by_choice(Application.STATUS_CHOICES, "status"),
**_count_by_choice(
Application.PARTICIPATION_MODE_CHOICES,
"participation_mode",
),
)

team_metrics = Team.objects.filter(application__program=program).aggregate(
total=Count("id", distinct=True),
accepted_members=Count(
"members",
filter=Q(members__status=TeamMember.STATUS_ACCEPTED),
distinct=True,
),
)

submission_metrics = Submission.objects.filter(program=program).aggregate(
total=Count("id"),
applications_with_submitted_solution=Count(
"application_id",
filter=Q(
status__in=(
Submission.STATUS_SUBMITTED,
Submission.STATUS_FINAL,
)
),
distinct=True,
),
**_count_by_choice(Submission.STATUS_CHOICES, "status"),
)

assignment_metrics = SubmissionExpertAssignment.objects.filter(
submission__program=program
).aggregate(
total=Count("id"),
**_count_by_choice(SubmissionExpertAssignment.STATUS_CHOICES, "status"),
)

evaluation_metrics = Evaluation.objects.filter(submission__program=program).aggregate(
total=Count("id"),
**_count_by_choice(Evaluation.STATUS_CHOICES, "status"),
)

return {
"program": {
"id": program.pk,
"name": program.name,
},
"registrations": registrations,
"applications": {
"total": application_metrics["total"],
"by_status": _choice_counts(
application_metrics,
"status",
Application.STATUS_CHOICES,
),
"by_participation_mode": _choice_counts(
application_metrics,
"participation_mode",
Application.PARTICIPATION_MODE_CHOICES,
),
},
"teams": team_metrics,
"submissions": {
"total": submission_metrics["total"],
"by_status": _choice_counts(
submission_metrics,
"status",
Submission.STATUS_CHOICES,
),
"applications_with_submitted_solution": submission_metrics[
"applications_with_submitted_solution"
],
},
"expert_assignments": {
"total": assignment_metrics["total"],
"by_status": _choice_counts(
assignment_metrics,
"status",
SubmissionExpertAssignment.STATUS_CHOICES,
),
},
"evaluations": {
"total": evaluation_metrics["total"],
"by_status": _choice_counts(
evaluation_metrics,
"status",
Evaluation.STATUS_CHOICES,
),
},
}
Loading
Loading