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
44 changes: 44 additions & 0 deletions docs/modules/program-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Панель организатора программы · V1

Дизайн V1 утверждён пользователем 17.10.2026 («В целом да»).
[Figma](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=201-5390).

## Контракт

Существующие `/programs/:id/`, `/projects/`, `/manager-overview/`, экспорт и legacy scoring остаются совместимыми. Новая панель использует отдельную приватную проекцию: публичный пользовательский DTO не содержит почту участников.

Все новые URL начинаются с `/programs/:id/management/`.

| URL | Метод | Результат |
| --- | --- | --- |
| пустой путь | GET | Основное, даты, режим программы, `updated_at`, материалы |
| `participants/` | GET | DRF `count/next/previous/results`, ФИО, почта, город, регистрация именно в программе |
| `projects/` | GET | Связи Project × Program, руководитель, сдача, оценивание, кейс и презентация |
| `projects/:link_id/team/` | GET | Руководитель и действующие Collaborator, без приглашений и изменений команды |
| `projects/:link_id/evaluation/` | GET | Оценки критериев этой программы, реальные назначения и `can_rate` |
| `settings/basic/` | PATCH | Только поля содержания и изображений из макета |
| `settings/dates/` | PATCH | Только пять календарных полей из макета |
| `materials/` | POST | Материал с названием и одним источником: URL или UserFile |
| `materials/:id/` | PATCH, DELETE | Обновление или отвязка материала от программы |

Чтение и запись доступны только 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, выдача ограничена страницей.

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

## Сохранение и ошибки

PATCH настроек передаёт только изменённые поля выбранного раздела и обязательный `expected_updated_at`. Program блокируется `select_for_update`; несовпадение версии —409 `stale_settings`. Переход истёкшего дедлайна в будущее или снятие необязательного дедлайна требует `confirm_reopen=true`, иначе409 `reopen_confirmation_required`, без сохранения. Пустой отдельный срок сдачи по-прежнему использует окончание регистрации. Новая хронология или новые правила сдачи не вводятся.

PATCH материала передаёт название, один источник и `expected_updated_at`; DELETE принимает ту же версию в query. Конфликт —409 `stale_material`. Для нового файла проверяется владелец; сохранённый источник допустим при редактировании другим manager той же программы. Удаление материала и замена источника сохраняют исходный UserFile/CDN-файл. Схема БД не меняется.

400 означает ошибку значений или неподдерживаемое поле. Поля managers, draft, конкурсность, права, критерии, кейсы и распределение экспертов через настройки не изменяются.

## Angular и внедрение

Angular получает camelCase через существующий interceptor, передаёт версии, обрабатывает409, сохраняет введённые значения при ошибке и запрашивает подтверждение ухода. После сохранения сбрасывает приватный/публичный кеш программы. Даты редактируются явно в Москве UTC+3; неизменённые поля не округляются.

Переход к оценке открывает чтение выбранного проекта. Для пользователя с существующими правами эксперта дополнительная ссылка ведёт в legacy rating с точным `project_id`; фильтр не расширяет queryset доступных эксперту проектов. POST оценок и права эксперта не меняются.

Порядок внедрения: backend API → Angular UI. Только dev; merge и deployment выполняет пользователь. Отдельные действия участников, массовые операции, возврат проекта на доработку, исключение и смена кейса в V1 не входят.
195 changes: 195 additions & 0 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Панель организатора поверх существующей модели программ."""

from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from partner_programs.models import PartnerProgramMaterial
from partner_programs.pagination import ProgramAttentionPagination
from partner_programs.serializers.management import (
ManagementListQuery,
ManagementProjectQuery,
ManagementSettingsSerializer,
ManagementMaterialWrite,
)
from partner_programs.services.case_fields import get_program_case_field
from partner_programs.services.management import (
managed_program,
management_data,
participant_rows,
participant_data,
management_project_rows,
project_data,
managed_project,
team_data,
evaluation_data,
material_data,
)


def conflict(code, detail):
return Response({"code": code, "detail": detail}, status=status.HTTP_409_CONFLICT)


class ManagementBase(APIView):
permission_classes = [IsAuthenticated]


class ManagementView(ManagementBase):
def get(self, request, pk):
return Response(management_data(managed_program(pk, request.user)))


class ManagementParticipantsView(ManagementBase):
def get(self, request, pk):
program = managed_program(pk, request.user)
query = ManagementListQuery(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
)
return paginator.get_paginated_response([participant_data(p) for p in page])


class ManagementProjectsView(ManagementBase):
def get(self, request, pk):
program = managed_program(pk, request.user)
field = get_program_case_field(program)
options = field.get_options_list() if field else []
query = ManagementProjectQuery(
data=request.query_params, context={"case_options": options}
)
query.is_valid(raise_exception=True)
paginator = ProgramAttentionPagination(query.validated_data)
page = paginator.paginate_queryset(
management_project_rows(program, query.validated_data, field),
request,
view=self,
)
response = paginator.get_paginated_response(
[project_data(p, program) for p in page]
)
response.data.update(
{"case_options": options, "is_competitive": program.is_competitive}
)
return response


class ManagementTeamView(ManagementBase):
def get(self, request, pk, link_id):
program = managed_program(pk, request.user)
return Response(team_data(managed_project(program, link_id)))


class ManagementEvaluationView(ManagementBase):
def get(self, request, pk, link_id):
program = managed_program(pk, request.user)
return Response(
evaluation_data(program, managed_project(program, link_id), request.user)
)


class ManagementSettingsView(ManagementBase):
@transaction.atomic
def patch(self, request, pk, section):
if section not in ("basic", "dates"):
return Response(status=404)
program = managed_program(pk, request.user, lock=True)
serializer = ManagementSettingsSerializer(
program, data=request.data, context={"section": section}
)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
if values.pop("expected_updated_at") != program.datetime_updated:
return conflict(
"stale_settings",
"Программа изменена другим организатором. Обновите данные перед сохранением.",
)
confirm = values.pop("confirm_reopen", False)
before = [
program.datetime_registration_ends,
program.get_project_submission_deadline(),
program.datetime_evaluation_ends,
]
for key, value in values.items():
setattr(program, key, value)
after = [
program.datetime_registration_ends,
program.get_project_submission_deadline(),
program.datetime_evaluation_ends,
]
now = timezone.now()
if (
any(
old is not None and old < now and (new is None or new >= now)
for old, new in zip(before, after)
)
and not confirm
):
return conflict(
"reopen_confirmation_required",
"Изменение срока снова откроет приём или оценивание. Подтвердите сохранение.",
)
program.save(update_fields=[*values, "datetime_updated"])
return Response(management_data(program))


class ManagementMaterialsView(ManagementBase):
@transaction.atomic
def post(self, request, pk):
program = managed_program(pk, request.user)
serializer = ManagementMaterialWrite(
data=request.data, context={"user": request.user}
)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
values.pop("expected_updated_at", None)
material = PartnerProgramMaterial.objects.create(program=program, **values)
return Response(material_data(material), status=201)


class ManagementMaterialDetailView(ManagementBase):
def get_material(self, request, pk, material_id):
program = managed_program(pk, request.user)
return get_object_or_404(
PartnerProgramMaterial.objects.select_for_update(),
pk=material_id,
program=program,
)

@transaction.atomic
def patch(self, request, pk, material_id):
material = self.get_material(request, pk, material_id)
serializer = ManagementMaterialWrite(
data=request.data, context={"user": request.user, "instance": material}
)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
if values.pop("expected_updated_at") != material.datetime_updated:
return conflict(
"stale_material",
"Материал изменён другим организатором. Обновите список.",
)
for key, value in values.items():
setattr(material, key, value)
material.save()
return Response(material_data(material))

@transaction.atomic
def delete(self, request, pk, material_id):
material = self.get_material(request, pk, material_id)
field = serializers.DateTimeField()
expected = field.run_validation(request.query_params.get("expected_updated_at"))
if expected != material.datetime_updated:
return conflict(
"stale_material",
"Материал изменён другим организатором. Обновите список.",
)
# Удаляется только материал программы; исходный UserFile остаётся доступен владельцу.
material.delete()
return Response(status=204)
Loading
Loading