From 58f460c4956e8c78eb49adba6bab897f09454b79 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 02:24:29 +0300 Subject: [PATCH] feat(programs): add scoped organizer management API --- docs/modules/program-management.md | 44 ++ partner_programs/management_views.py | 195 ++++++++ partner_programs/serializers/management.py | 141 ++++++ partner_programs/services/analytics.py | 5 +- partner_programs/services/management.py | 237 ++++++++++ partner_programs/tests/test_management_api.py | 430 ++++++++++++++++++ partner_programs/urls.py | 47 ++ project_rates/tests/test_project_list_api.py | 19 + projects/filters.py | 1 + 9 files changed, 1117 insertions(+), 2 deletions(-) create mode 100644 docs/modules/program-management.md create mode 100644 partner_programs/management_views.py create mode 100644 partner_programs/serializers/management.py create mode 100644 partner_programs/services/management.py create mode 100644 partner_programs/tests/test_management_api.py diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md new file mode 100644 index 00000000..bea1c497 --- /dev/null +++ b/docs/modules/program-management.md @@ -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 не входят. diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py new file mode 100644 index 00000000..888c2cce --- /dev/null +++ b/partner_programs/management_views.py @@ -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) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py new file mode 100644 index 00000000..20569b13 --- /dev/null +++ b/partner_programs/serializers/management.py @@ -0,0 +1,141 @@ +"""Контракты панели организатора; поля ролей и жизненного цикла не редактируются.""" + +from urllib.parse import urlsplit + +from rest_framework import serializers + +from files.models import UserFile +from partner_programs.models import PartnerProgram + + +class StrictFieldsMixin: + def to_internal_value(self, data): + unknown = set(data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + {key: "Поле не поддерживается." for key in sorted(unknown)} + ) + return super().to_internal_value(data) + + +class HttpURLField(serializers.URLField): + def to_internal_value(self, data): + value = super().to_internal_value(data) + if urlsplit(value).scheme.lower() not in ("http", "https"): + raise serializers.ValidationError("Укажите ссылку http или https.") + return value + + +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) + offset = serializers.IntegerField(min_value=0, default=0) + + +class ManagementProjectQuery(ManagementListQuery): + submission = serializers.ChoiceField( + choices=("all", "submitted", "not_submitted"), default="all" + ) + case_scope = serializers.ChoiceField( + choices=("all", "selected", "without_case"), default="all" + ) + case_name = serializers.CharField(required=False, allow_blank=True, max_length=1000) + + def validate(self, attrs): + if attrs["case_scope"] == "selected": + if attrs.get("case_name") not in self.context["case_options"]: + raise serializers.ValidationError( + {"case_name": "Выберите доступный кейс."} + ) + elif attrs.get("case_name"): + raise serializers.ValidationError( + {"case_name": "Кейс задан без selected scope."} + ) + return attrs + + +BASIC_FIELDS = ( + "name", + "city", + "tag", + "description", + "image_address", + "cover_image_address", + "advertisement_image_address", + "registration_link", + "presentation_address", +) +DATE_FIELDS = ( + "datetime_started", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "datetime_finished", +) + + +class ManagementSettingsSerializer(StrictFieldsMixin, serializers.ModelSerializer): + expected_updated_at = serializers.DateTimeField(write_only=True) + confirm_reopen = serializers.BooleanField( + write_only=True, required=False, default=False + ) + + def get_fields(self): + fields = super().get_fields() + allowed = BASIC_FIELDS if self.context["section"] == "basic" else DATE_FIELDS + for name in list(fields): + if name not in (*allowed, "expected_updated_at", "confirm_reopen"): + del fields[name] + for name in allowed: + fields[name].required = False + if name.endswith("_address") or name == "registration_link": + fields[name] = HttpURLField( + required=False, allow_null=True, allow_blank=True + ) + fields["expected_updated_at"].required = True + return fields + + def validate(self, attrs): + editable = BASIC_FIELDS if self.context["section"] == "basic" else DATE_FIELDS + if not any(key in attrs for key in editable): + raise serializers.ValidationError("Нет изменённых полей.") + return attrs + + class Meta: + model = PartnerProgram + fields = (*BASIC_FIELDS, *DATE_FIELDS, "expected_updated_at", "confirm_reopen") + extra_kwargs = {"name": {"allow_blank": False}} + + +class ManagementMaterialWrite(StrictFieldsMixin, serializers.Serializer): + title = serializers.CharField(max_length=255) + url = HttpURLField(required=False, allow_blank=True, allow_null=True, default=None) + file = serializers.CharField( + required=False, allow_blank=True, allow_null=True, default=None + ) + expected_updated_at = serializers.DateTimeField(required=False) + + def validate(self, attrs): + instance = self.context.get("instance") + if instance is not None and "expected_updated_at" not in attrs: + raise serializers.ValidationError( + {"expected_updated_at": "Обязательное поле."} + ) + file_id, url = attrs.get("file"), attrs.get("url") + if bool(file_id) == bool(url): + raise serializers.ValidationError("Укажите файл или ссылку — один источник.") + if file_id: + file = UserFile.objects.filter(pk=file_id).first() + user = self.context["user"] + retained = instance is not None and instance.file_id == file_id + if file is None or not ( + retained or file.user_id == user.pk or user.is_staff or user.is_superuser + ): + raise serializers.ValidationError({"file": "Файл недоступен."}) + attrs["file"] = file + attrs["url"] = file.link + else: + attrs["file"] = None + return attrs diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 8478c789..487bf0e9 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -233,14 +233,15 @@ def projects_not_submitted_rows(program): ) -def _solution_rows(program): +def _solution_rows(program, *, rows=None): """Общая SQL-классификация работ программы для overview и детализации. В distributed учитываются только реальные назначения и общая проверка завершённости каждого назначения. В open достаточно первой оценки по критериям программы; прогресс назначений в этом режиме неприменим (null). """ - rows = PartnerProgramProject.objects.filter(partner_program_id=program.pk) + if rows is None: + rows = PartnerProgramProject.objects.filter(partner_program_id=program.pk) if program.is_distributed_evaluation: assignment_totals = ( annotated_assignment_queryset(program.pk) diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py new file mode 100644 index 00000000..045d8b14 --- /dev/null +++ b/partner_programs/services/management.py @@ -0,0 +1,237 @@ +"""Program-scoped чтение для организатора без изменений участников и проектов.""" + +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import PermissionDenied + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.permissions import can_manage_program +from partner_programs.serializers.management import BASIC_FIELDS, DATE_FIELDS +from partner_programs.services.analytics import _solution_rows +from partner_programs.services.case_analytics import project_case_links +from project_rates.models import Criteria, ProjectScore +from project_rates.services import get_projects_for_rate_queryset +from partner_programs.services.assignment_analytics import ( + assignment_rows, + build_assignment, +) +from django.utils import timezone + + +def managed_program(pk, user, *, lock=False): + rows = ( + PartnerProgram.objects.select_for_update() + if lock + else PartnerProgram.objects.all() + ) + program = get_object_or_404(rows, pk=pk) + if not can_manage_program(user, program): + raise PermissionDenied("У вас нет доступа к управлению этой программой.") + return program + + +def person_name(user): + return ( + " ".join(filter(None, (user.first_name, user.last_name, user.patronymic))).strip() + or "Без имени" + ) + + +def material_data(material): + file = material.file + return { + "id": material.pk, + "title": material.title, + "url": material.url, + "file": { + "link": file.link, + "name": file.name, + "extension": file.extension, + "size": file.size, + } + if file + else None, + "updated_at": material.datetime_updated, + } + + +def management_data(program): + return { + "id": program.pk, + **{name: getattr(program, name) for name in (*BASIC_FIELDS, *DATE_FIELDS)}, + "updated_at": program.datetime_updated, + "is_competitive": program.is_competitive, + "is_distributed_evaluation": program.is_distributed_evaluation, + "materials": [ + material_data(m) + for m in program.materials.select_related("file").order_by("pk") + ], + } + + +def participant_rows(program, search): + rows = ( + PartnerProgramUserProfile.objects.filter( + partner_program=program, user__isnull=False + ) + .select_related("user") + .order_by("datetime_created", "pk") + ) + if search: + # Каждый фрагмент может совпадать с любой частью ФИО или почты. + for part in search.split(): + rows = rows.filter( + Q(user__first_name__icontains=part) + | Q(user__last_name__icontains=part) + | Q(user__patronymic__icontains=part) + | Q(user__email__icontains=part) + ) + return rows + + +def participant_data(profile): + return { + "id": profile.pk, + "user_id": profile.user_id, + "name": person_name(profile.user), + "email": profile.user.email, + "city": profile.user.city, + "registered_at": profile.datetime_created, + } + + +def management_project_rows(program, query, field): + rows = _solution_rows( + program, rows=project_case_links(program, field=field) + ).select_related("project", "project__leader") + if query["search"]: + rows = rows.filter(project__name__icontains=query["search"]) + if query["submission"] != "all": + rows = rows.filter(submitted=query["submission"] == "submitted") + if query["case_scope"] == "selected": + rows = rows.filter(case_name=query["case_name"]) + elif query["case_scope"] == "without_case": + rows = rows.filter(case_name__isnull=True) + return rows.order_by("datetime_created", "pk") + + +def project_data(link, program): + status = link.status if program.is_competitive else "not_applicable" + return { + "id": link.pk, + "project_id": link.project_id, + "name": link.project.name, + "leader": { + "id": link.project.leader_id, + "name": person_name(link.project.leader), + }, + "submitted": link.submitted, + "evaluation_status": status, + "assignments_total": link.assignments_total, + "assignments_completed": link.assignments_completed, + "case_name": link.case_name, + "presentation_url": link.project.presentation_address, + } + + +def managed_project(program, link_id): + return get_object_or_404( + PartnerProgramProject.objects.select_related("project", "project__leader"), + pk=link_id, + partner_program=program, + ) + + +def team_data(link): + project = link.project + people = [ + { + "id": project.leader_id, + "name": person_name(project.leader), + "role": "Руководитель", + } + ] + people.extend( + { + "id": member.user_id, + "name": person_name(member.user), + "role": member.role or "Участник команды", + } + for member in project.collaborator_set.select_related("user") + .exclude(user_id=project.leader_id) + .order_by("pk") + ) + return { + "project_id": project.pk, + "program_link_id": link.pk, + "name": project.name, + "members": people, + } + + +def evaluation_data(program, link, user): + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + scores = list( + ProjectScore.objects.filter( + project_id=link.project_id, criteria__partner_program=program + ) + .select_related("user") + .order_by("user_id", "criteria_id") + ) + assignments = ( + [ + build_assignment(row, now=timezone.now()) + for row in assignment_rows(program.pk).filter(project_id=link.project_id) + ] + if program.is_distributed_evaluation + else [] + ) + experts = { + a["expert"]["user_id"]: { + "id": a["expert"]["user_id"], + "name": a["expert"]["full_name"], + "status": a["status"], + } + for a in assignments + } + for score in scores: + experts.setdefault( + score.user_id, + {"id": score.user_id, "name": person_name(score.user), "status": "completed"}, + ) + values = {(s.user_id, s.criteria_id): s.value for s in scores} + for expert in experts.values(): + expert["criteria"] = [ + { + "id": c.pk, + "name": c.name, + "type": c.type, + "value": values.get((expert["id"], c.pk)), + "is_scored": (expert["id"], c.pk) in values, + } + for c in criteria + ] + eligible = ( + program.is_competitive + and link.submitted + and user.user_type == 3 + and program.experts.filter(user_id=user.pk).exists() + ) + can_rate = bool( + eligible + and get_projects_for_rate_queryset(program=program, user=user, field_filters={}) + .filter(pk=link.project_id) + .exists() + ) + return { + "project_id": link.project_id, + "program_link_id": link.pk, + "name": link.project.name, + "submitted": link.submitted, + "can_rate": can_rate, + "experts": list(experts.values()), + } diff --git a/partner_programs/tests/test_management_api.py b/partner_programs/tests/test_management_api.py new file mode 100644 index 00000000..8e1dee90 --- /dev/null +++ b/partner_programs/tests/test_management_api.py @@ -0,0 +1,430 @@ +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from files.models import UserFile +from partner_programs.models import PartnerProgramMaterial, PartnerProgramUserProfile +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectScore, ProjectExpertAssignment +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ManagementAPITests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(prefix="management-manager") + cls.other = create_user(prefix="management-other") + cls.program = create_partner_program(is_competitive=True) + cls.program.managers.add(cls.manager) + cls.foreign = create_partner_program(is_competitive=True) + cls.foreign.managers.add(cls.other) + cls.member = create_program_member( + cls.program, + user=create_user(first_name="Анна", last_name="Крылова", city="Москва"), + ) + cls.project = create_project( + leader=cls.member.user, presentation_address="https://example.com/deck.pdf" + ) + cls.link = create_program_project( + cls.program, project=cls.project, submitted=True + ) + cls.foreign_link = create_program_project(cls.foreign) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def url(self, name="management", **kwargs): + return reverse( + "partner_programs:" + name, kwargs={"pk": self.program.pk, **kwargs} + ) + + def test_program_access_is_object_scoped_and_authenticated(self): + for user, expected in [ + (self.manager, 200), + (self.other, 403), + (self.member.user, 403), + (None, 401), + ]: + self.client.force_authenticate(user) + self.assertEqual(self.client.get(self.url()).status_code, expected) + self.client.force_authenticate(create_user(is_staff=True)) + self.assertEqual(self.client.get(self.url()).status_code, 200) + + def test_expert_role_does_not_grant_management(self): + expert = create_rate_expert(program=self.program) + self.client.force_authenticate(expert) + self.assertEqual(self.client.get(self.url()).status_code, 403) + + def test_all_resources_reject_foreign_manager(self): + self.client.force_authenticate(self.other) + endpoints = [ + self.url(), + self.url("management-participants"), + self.url("management-projects"), + self.url("management-team", link_id=self.link.pk), + self.url("management-evaluation", link_id=self.link.pk), + ] + for endpoint in endpoints: + with self.subTest(endpoint=endpoint): + self.assertEqual(self.client.get(endpoint).status_code, 403) + self.assertEqual( + self.client.patch( + self.url("management-settings", section="basic"), + {"name": "attack"}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + self.url("management-materials"), + {"title": "attack", "url": "https://example.com"}, + format="json", + ).status_code, + 403, + ) + + def test_participants_have_email_and_program_registration_and_server_search(self): + create_program_member(self.foreign, user=self.member.user) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, user=None, partner_program_data={} + ) + response = self.client.get( + self.url("management-participants"), {"search": "Крылова Анна", "limit": 1} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + row = response.data["results"][0] + self.assertEqual(row["id"], self.member.pk) + self.assertEqual(row["email"], self.member.user.email) + self.assertEqual(row["registered_at"], self.member.datetime_created) + by_email = self.client.get( + self.url("management-participants"), {"search": self.member.user.email} + ) + self.assertEqual(by_email.data["count"], 1) + + def test_bad_query_is_not_silently_defaulted(self): + for query in [ + {"limit": 101}, + {"limit": 0}, + {"offset": -1}, + {"offset": "bad"}, + {"search": "a" * 201}, + ]: + with self.subTest(query=query): + self.assertEqual( + self.client.get( + self.url("management-participants"), query + ).status_code, + 400, + ) + + def test_projects_use_link_submission_and_current_program_scores(self): + expert = create_rate_expert(program=self.foreign) + criterion = Criteria.objects.create( + partner_program=self.foreign, + name="Foreign", + type="int", + min_value=0, + max_value=10, + ) + ProjectScore.objects.create( + project=self.project, criteria=criterion, user=expert, value="8" + ) + response = self.client.get(self.url("management-projects")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + row = response.data["results"][0] + self.assertEqual(row["id"], self.link.pk) + self.assertEqual(row["evaluation_status"], "awaiting_evaluation") + self.assertEqual(row["presentation_url"], self.project.presentation_address) + self.assertTrue(row["submitted"]) + self.assertEqual( + self.client.get( + self.url("management-projects"), {"submission": "not_submitted"} + ).data["count"], + 0, + ) + + def test_team_is_read_only_and_scoped_to_link(self): + member = create_user() + Collaborator.objects.bulk_create( + [Collaborator(project=self.project, user=member, role="Разработчик")] + ) + response = self.client.get(self.url("management-team", link_id=self.link.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual( + [m["id"] for m in response.data["members"]], + [self.project.leader_id, member.pk], + ) + self.assertEqual( + self.client.get( + self.url("management-team", link_id=self.foreign_link.pk) + ).status_code, + 404, + ) + self.assertEqual( + self.client.post( + self.url("management-team", link_id=self.link.pk), {} + ).status_code, + 405, + ) + + def test_manager_can_read_scores_but_cannot_become_expert(self): + expert = create_rate_expert(program=self.program) + criterion = Criteria.objects.create( + partner_program=self.program, + name="Точность", + type="int", + min_value=0, + max_value=10, + ) + ProjectScore.objects.create( + project=self.project, criteria=criterion, user=expert, value="7" + ) + response = self.client.get( + self.url("management-evaluation", link_id=self.link.pk) + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["can_rate"]) + self.assertEqual(response.data["experts"][0]["id"], expert.pk) + self.assertEqual( + self.client.get( + self.url("management-evaluation", link_id=self.foreign_link.pk) + ).status_code, + 404, + ) + + def test_existing_manager_expert_can_rate_only_assigned_submitted_project(self): + expert = create_rate_expert(program=self.program) + self.program.managers.add(expert) + self.program.is_distributed_evaluation = True + self.program.save() + self.client.force_authenticate(expert) + url = self.url("management-evaluation", link_id=self.link.pk) + self.assertFalse(self.client.get(url).data["can_rate"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=expert.expert + ) + self.assertTrue(self.client.get(url).data["can_rate"]) + unsubmitted = create_program_project( + self.program, project=create_project(leader=self.member.user), submitted=False + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=unsubmitted.project, + expert=expert.expert, + ) + self.assertFalse( + self.client.get( + self.url("management-evaluation", link_id=unsubmitted.pk) + ).data["can_rate"] + ) + + def version(self): + return self.client.get(self.url()).data["updated_at"].isoformat() + + def test_basic_save_is_whitelisted_and_optimistic(self): + url = self.url("management-settings", section="basic") + version = self.version() + response = self.client.patch( + url, {"name": "Новое имя", "expected_updated_at": version}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["name"], "Новое имя") + stale = self.client.patch( + url, + {"name": "Потерянная запись", "expected_updated_at": version}, + format="json", + ) + self.assertEqual(stale.status_code, 409) + self.assertEqual(stale.data["code"], "stale_settings") + for field, value in [ + ("managers", [self.other.pk]), + ("draft", True), + ("is_competitive", False), + ]: + with self.subTest(field=field): + self.assertEqual( + self.client.patch( + url, + {field: value, "expected_updated_at": self.version()}, + format="json", + ).status_code, + 400, + ) + + def test_reopening_deadline_requires_confirmation_and_preserves_fallback(self): + self.program.datetime_registration_ends = timezone.now() - timedelta(days=2) + self.program.datetime_project_submission_ends = None + self.program.save() + url = self.url("management-settings", section="dates") + payload = { + "datetime_registration_ends": ( + timezone.now() + timedelta(days=2) + ).isoformat(), + "expected_updated_at": self.version(), + } + response = self.client.patch(url, payload, format="json") + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "reopen_confirmation_required") + self.program.refresh_from_db() + self.assertLess(self.program.datetime_registration_ends, timezone.now()) + payload["confirm_reopen"] = True + self.assertEqual(self.client.patch(url, payload, format="json").status_code, 200) + self.program.refresh_from_db() + self.assertEqual( + self.program.get_project_submission_deadline(), + self.program.datetime_registration_ends, + ) + + def test_material_file_ownership_and_delete_keeps_source(self): + owned = UserFile.objects.create( + link="https://example.com/own.pdf", user=self.manager + ) + other = UserFile.objects.create( + link="https://example.com/other.pdf", user=self.other + ) + url = self.url("management-materials") + self.assertEqual( + self.client.post( + url, {"title": "Чужой", "file": other.link}, format="json" + ).status_code, + 400, + ) + response = self.client.post( + url, {"title": "Свой", "file": owned.link}, format="json" + ) + self.assertEqual(response.status_code, 201, response.data) + material = PartnerProgramMaterial.objects.get(pk=response.data["id"]) + delete_url = self.url("management-material", material_id=material.pk) + self.assertEqual(self.client.delete(delete_url).status_code, 400) + self.assertEqual( + self.client.delete( + delete_url, {"expected_updated_at": "2000-01-01T00:00:00Z"} + ).status_code, + 400, + ) + response = self.client.delete( + delete_url + + "?expected_updated_at=" + + material.datetime_updated.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + ) + self.assertEqual(response.status_code, 204, response.data) + self.assertTrue(UserFile.objects.filter(pk=owned.pk).exists()) + + def test_material_update_is_scoped_and_conflict_checked(self): + material = PartnerProgramMaterial.objects.create( + program=self.program, title="Правила", url="https://example.com/rules" + ) + url = self.url("management-material", material_id=material.pk) + payload = { + "title": "Правила 2", + "url": "https://example.com/rules-2", + "file": None, + "expected_updated_at": material.datetime_updated.isoformat(), + } + self.assertEqual(self.client.patch(url, payload, format="json").status_code, 200) + self.assertEqual(self.client.patch(url, payload, format="json").status_code, 409) + self.client.force_authenticate(self.other) + self.assertEqual(self.client.patch(url, payload, format="json").status_code, 403) + + def test_settings_and_materials_reject_wrong_methods(self): + self.assertEqual( + self.client.get(self.url("management-settings", section="basic")).status_code, + 405, + ) + self.assertEqual( + self.client.get(self.url("management-material", material_id=1)).status_code, + 405, + ) + + def test_distributed_evaluation_uses_completed_real_assignments(self): + self.program.is_distributed_evaluation = True + self.program.max_project_rates = 2 + self.program.save() + Criteria.objects.filter(partner_program=self.program).delete() + criterion = Criteria.objects.create( + partner_program=self.program, + name="Точность", + type="int", + min_value=0, + max_value=10, + ) + first = create_rate_expert(program=self.program) + second = create_rate_expert(program=self.program) + for user in (first, second): + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=user.expert + ) + ProjectScore.objects.create( + project=self.project, criteria=criterion, user=first, value="0" + ) + row = self.client.get(self.url("management-projects")).data["results"][0] + self.assertEqual( + ( + row["evaluation_status"], + row["assignments_completed"], + row["assignments_total"], + ), + ("partially_evaluated", 1, 2), + ) + ProjectScore.objects.create( + project=self.project, criteria=criterion, user=second, value="8" + ) + self.assertEqual( + self.client.get(self.url("management-projects")).data["results"][0][ + "evaluation_status" + ], + "evaluated", + ) + + def test_noncompetitive_evaluation_is_not_applicable(self): + self.program.is_competitive = False + self.program.save() + response = self.client.get(self.url("management-projects")) + self.assertEqual( + response.data["results"][0]["evaluation_status"], "not_applicable" + ) + + def test_pagination_uses_profiles_of_this_program_only(self): + second = create_program_member(self.program) + create_program_member(self.foreign) + response = self.client.get( + self.url("management-participants"), {"limit": 1, "offset": 1} + ) + self.assertEqual(response.data["count"], 2) + self.assertEqual([row["id"] for row in response.data["results"]], [second.pk]) + + def test_file_material_can_be_changed_to_link_without_deleting_file(self): + file = UserFile.objects.create( + link="https://example.com/retained.pdf", user=self.manager + ) + material = PartnerProgramMaterial.objects.create( + program=self.program, title="Файл", file=file + ) + response = self.client.patch( + self.url("management-material", material_id=material.pk), + { + "title": "Ссылка", + "file": None, + "url": "https://example.com/rules", + "expected_updated_at": material.datetime_updated.isoformat(), + }, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + material.refresh_from_db() + self.assertIsNone(material.file_id) + self.assertTrue(UserFile.objects.filter(pk=file.pk).exists()) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 840618ea..7c8c35f4 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -30,9 +30,56 @@ from partner_programs.widget_views import ProgramRoleWidgetAPIView +from partner_programs.management_views import ( + ManagementView, + ManagementParticipantsView, + ManagementProjectsView, + ManagementTeamView, + ManagementEvaluationView, + ManagementSettingsView, + ManagementMaterialsView, + ManagementMaterialDetailView, +) + app_name = "partner_programs" urlpatterns = [ + path("/management/", ManagementView.as_view(), name="management"), + path( + "/management/participants/", + ManagementParticipantsView.as_view(), + name="management-participants", + ), + path( + "/management/projects/", + ManagementProjectsView.as_view(), + name="management-projects", + ), + path( + "/management/projects//team/", + ManagementTeamView.as_view(), + name="management-team", + ), + path( + "/management/projects//evaluation/", + ManagementEvaluationView.as_view(), + name="management-evaluation", + ), + path( + "/management/settings//", + ManagementSettingsView.as_view(), + name="management-settings", + ), + path( + "/management/materials/", + ManagementMaterialsView.as_view(), + name="management-materials", + ), + path( + "/management/materials//", + ManagementMaterialDetailView.as_view(), + name="management-material", + ), path( "/analytics-widget/", ProgramRoleWidgetAPIView.as_view(), diff --git a/project_rates/tests/test_project_list_api.py b/project_rates/tests/test_project_list_api.py index 9a90d2bf..c80ff927 100644 --- a/project_rates/tests/test_project_list_api.py +++ b/project_rates/tests/test_project_list_api.py @@ -39,6 +39,25 @@ def setUp(self): def _projects_url(self) -> str: return f"/rate-project/{self.program.id}" + def test_exact_project_deep_link_still_uses_expert_queryset(self): + self.client.force_authenticate(self.expert) + response = self.client.get( + self._projects_url(), {"project_id": self.project_2.pk} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual([p["id"] for p in response.data["results"]], [self.project_2.pk]) + foreign = create_rate_project(leader=self.leader, name="Foreign program") + response = self.client.get(self._projects_url(), {"project_id": foreign.pk}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + self.client.force_authenticate(self.leader) + self.assertEqual( + self.client.get( + self._projects_url(), {"project_id": self.project_2.pk} + ).status_code, + 403, + ) + def test_post_filters_projects_by_program_field_values(self): field = PartnerProgramField.objects.create( partner_program=self.program, diff --git a/projects/filters.py b/projects/filters.py index 9ece8dc4..951733b2 100644 --- a/projects/filters.py +++ b/projects/filters.py @@ -87,6 +87,7 @@ def filter_by_have_expert_rates(self, queryset, name, value): return queryset.filter(id__in=rated_projects_ids) return queryset.exclude(id__in=rated_projects_ids) + project_id = filters.NumberFilter(field_name="id", lookup_expr="exact") name__contains = filters.Filter(field_name="name", lookup_expr="contains") description__contains = filters.Filter( field_name="description", lookup_expr="contains"