From 225060c6efe50df96c5a8416e333cac6b2233c15 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 27 Jul 2026 16:02:25 +0300 Subject: [PATCH 1/2] Add expert evaluation backend flow --- docs/expert-evaluation-api.md | 232 +++++ docs/submission-evaluation-domain.md | 18 +- partner_programs/evaluation_urls.py | 17 + partner_programs/evaluation_views.py | 402 +++++++ partner_programs/expert_urls.py | 17 + partner_programs/serializers/evaluations.py | 295 ++++++ .../serializers/submission_assignments.py | 29 + partner_programs/services/evaluations.py | 612 +++++++++++ .../submission_assignment_views.py | 8 + partner_programs/submission_urls.py | 11 + .../tests/test_expert_evaluation_api.py | 978 ++++++++++++++++++ .../tests/test_submission_assignment_api.py | 2 + partner_programs/throttling.py | 12 + partner_programs/urls.py | 14 + procollab/settings.py | 9 + procollab/urls.py | 8 + 16 files changed, 2655 insertions(+), 9 deletions(-) create mode 100644 docs/expert-evaluation-api.md create mode 100644 partner_programs/evaluation_urls.py create mode 100644 partner_programs/evaluation_views.py create mode 100644 partner_programs/expert_urls.py create mode 100644 partner_programs/serializers/evaluations.py create mode 100644 partner_programs/services/evaluations.py create mode 100644 partner_programs/tests/test_expert_evaluation_api.py diff --git a/docs/expert-evaluation-api.md b/docs/expert-evaluation-api.md new file mode 100644 index 00000000..a7b9b336 --- /dev/null +++ b/docs/expert-evaluation-api.md @@ -0,0 +1,232 @@ +# Expert Evaluation API + +## Roadmap + +- DEV-050 — Expert Submission API +- DEV-051 — Evaluation mutation API +- DEV-052 — Manager evaluation read API + +Roadmap-IDs: DEV-050, DEV-051, DEV-052 + +## Назначение + +Контур позволяет назначенному эксперту читать зафиксированную версию +`Submission`, вести черновик оценки и финально отправлять его. Менеджер +программы получает отдельный read-only доступ к оценкам экспертов. + +Эксперт оценивает `Submission`, а не изменяемый legacy `Project`. Права на +объект определяются `SubmissionExpertAssignment`, а не одной ролью Expert или +membership в программе. + +## Сущности + +- `SubmissionExpertAssignment` связывает конкретную Submission и Expert. +- `Evaluation` хранит форму оценки, комментарий и lifecycle. +- `EvaluationScore` хранит одно числовое значение и snapshot критерия. +- `project_rates.Criteria` временно используется как каталог критериев + программы. + +`ProjectScore`, `ProjectExpertAssignment` и `/rate-project/` в новый контур не +включены и не синхронизируются с Evaluation. + +## Статусы и переходы + +Assignment: + +- `assigned` — эксперт может создавать и изменять draft; +- `completed` — Evaluation отправлена, эксперт сохраняет read-only доступ; +- `revoked` — доступ эксперта закрыт, но draft и scores не удаляются. + +Evaluation: + +- `draft` — доступно атомарное обновление `comment` и полного набора `scores`; +- `submitted` — терминальное неизменяемое состояние. + +Финальный submit в одной транзакции переводит Evaluation в `submitted`, а +assignment в `completed`. Reopen и revision history отсутствуют. + +## Права доступа + +- Expert list/detail доступны только эксперту с текущим membership программы + и assignment `assigned` или `completed`. +- Создание и изменение draft требуют assignment `assigned`. +- Staff может открыть PII-safe expert detail в административном режиме. +- Владелец-эксперт, manager программы и staff могут читать Evaluation detail. +- Manager list/detail ограничены конкретной программой. +- Неназначенному или чужому эксперту объект возвращается как `404`. +- Manager API не имеет PATCH, DELETE, submit или reopen. + +Одна роль Expert, membership в программе или доступ к legacy ProjectScore не +создают object-level право. + +## Expert API + +### GET /expert/submissions/ + +Возвращает пагинированный список назначенных решений. Фильтры: + +- `program_id`; +- `submission_status`; +- `evaluation_status`: `draft`, `submitted`, `none`; +- стандартные `limit` и `offset`. + +Элемент содержит PII-safe Submission summary, Program, assignment и только +Evaluation текущего эксперта. + +### GET /expert/submissions/\/ + +Возвращает `title`, `description`, отдельное поле `links`, status, stage, +version, Program, assignment, собственную Evaluation и числовые Criteria. + +`Submission.form_data` не возвращается. В текущем домене нет надёжного +solution-only allowlist, поэтому частичная выдача JSON была бы небезопасной. +Также не возвращаются Application, participant, Team, registration data, +email, phone и Evaluation других экспертов. + +### GET /submissions/\/evaluations/my/ + +Возвращает существующую собственную Evaluation и scores. GET ничего не +создаёт. Отсутствующий draft или assignment возвращает `404`. + +### POST /submissions/\/evaluations/ + +Создаёт draft. `comment` и `scores` необязательны. Первый запрос возвращает +`201`; повторный POST возвращает существующий draft с `200` и не изменяет его. +Для submitted Evaluation возвращается `409`. + +### GET /evaluations/\/ + +Read-only detail для владельца-эксперта, manager соответствующей программы и +staff. + +### PATCH /evaluations/\/ + +Autosave владельца draft. Можно передать `comment`, `scores` или оба поля. Если +передан `scores`, старый набор заменяется целиком внутри транзакции. Пустой +массив удаляет все scores. Системные и lifecycle-поля read-only. + +### POST /evaluations/\/submit/ + +Проверяет полноту формы и атомарно завершает Evaluation и assignment. +Повторный submit идемпотентен: возвращает `200` и сохраняет первоначальный +`submitted_at`. + +## Manager API + +### GET /programs/\/submission-assignments/ + +Существующий контракт дополнен nullable-полем `evaluation` с полями `id`, +`status`, `updated_at`, `submitted_at`, `total_score`. Старое поле +`evaluation_status` сохранено. + +### GET /programs/\/evaluations/ + +Пагинированный read-only список. Фильтры: + +- `submission_id`; +- `expert_id`; +- `evaluation_status`; +- `assignment_status`; +- `stage_key`; +- `limit`, `offset`. + +Ответ содержит безопасные Submission/Expert/Assignment summaries, scores, +comment, total_score и timestamps. + +### GET /programs/\/evaluations/\/ + +Read-only detail в пределах программы manager. Mutation-методы отсутствуют. + +## Валидация критериев + +- Используются только Criteria той же Program типов `int` и `float`. +- Criterion ID не может повторяться в одном payload. +- Для `int` запрещена дробная часть. +- Значения проверяются по текущим `min_value` и `max_value` без преобразования + Decimal через float. +- PATCH сначала валидирует весь новый набор и только затем удаляет старый. +- Submit требует все текущие числовые Criteria программы и повторно проверяет + типы и диапазоны. +- Нечисловые Criteria не включаются в форму; свободный текст хранится в + `Evaluation.comment`. + +## Идемпотентность + +- Повторный create существующего draft возвращает его без изменения. +- Повторный PATCH с теми же данными не создаёт дублей. +- Повторный submit submitted Evaluation не меняет `submitted_at`. +- Уникальность `submission + expert` и `evaluation + criterion` дополнительно + защищена существующими constraints. + +## Транзакции и блокировки + +- Create блокирует assignment и обрабатывает race через вложенный savepoint + вокруг INSERT Evaluation. +- PATCH блокирует assignment и Evaluation; набор scores заменяется атомарно. +- Submit блокирует assignment и Evaluation в стабильном порядке, валидирует + полную форму и записывает одинаковый timestamp в Evaluation и assignment. +- Два конкурентных submit не создают противоречивое терминальное состояние. + +## Защита персональных данных + +Expert serializers построены отдельно от participant serializers. В ответах +нет Application user/creator, submitted_by, Team, TeamMember, registration +form_data, email или phone. `Submission.form_data` полностью исключён до +появления явной схемы разрешённых solution-полей. + +Manager responses содержат только минимальные имя/фамилию назначенного +эксперта и не содержат данные участника. Полные request payload не логируются +новым кодом. + +## Throttling + +- `evaluation_create`: `10/min`; +- `evaluation_update`: `120/min`; +- `evaluation_submit`: `20/min`. + +Scopes применяются только к соответствующим mutation-методам и не включают +глобальный DRF throttle. + +## Ошибки API + +- `200` — успешное чтение, autosave, повторный create или submit; +- `201` — первый draft create; +- `400` — payload, Criteria, тип, диапазон или неполная форма; +- `401` — отсутствует авторизация; +- `403` — нет доступа к list/manager endpoint; +- `404` — объект отсутствует или скрыт object-level политикой; +- `409` — lifecycle conflict Evaluation, assignment или Submission; +- `429` — превышен scoped throttle. + +## Проверка + +Основной regression-модуль: + +`partner_programs.tests.test_expert_evaluation_api` + +Он покрывает expert list/detail, PII regression, draft create, полную замену +scores, rollback, submit lifecycle, concurrent submit, manager read-only API, +filters, pagination и throttling. + +Также сохраняются model tests и Assignment API regression. + +## Известные ограничения + +- `total_score` остаётся `null`, пока у Program нет формулы. +- Criteria не имеют отдельного Evaluation order/weight/required policy: + обязательны все текущие числовые Criteria. +- Staff expert detail выбирает последнее активное или завершённое назначение + Submission для административного просмотра. +- Deadline `datetime_evaluation_ends` пока не блокирует новый контур. + +## Что не входит в контур + +- frontend и экспертный кабинет; +- Result, ranking и публикация участнику; +- экспорт Evaluation; +- уведомления; +- reopen и revision history; +- веса и формула total_score; +- изменение legacy Criteria; +- синхронизация с ProjectScore; +- изменение `/rate-project/`, Angular flow или старой выгрузки. diff --git a/docs/submission-evaluation-domain.md b/docs/submission-evaluation-domain.md index b2c8ab88..f2f7660f 100644 --- a/docs/submission-evaluation-domain.md +++ b/docs/submission-evaluation-domain.md @@ -9,11 +9,11 @@ - Assignment service и manager API реализованы: `GET/POST /programs//submission-assignments/` и `POST /submission-assignments//revoke/`; -- Expert Submission read API, Evaluation mutation API, frontend и `Result` - еще не реализованы; +- Expert Submission read API, Evaluation mutation API и manager Evaluation + read API реализованы в рамках DEV-050, DEV-051 и DEV-052; +- frontend, `Result`, ranking и публикация итогов еще не реализованы; - временно используется существующий `project_rates.Criteria`; - дедлайном MVP остается существующий `datetime_evaluation_ends`; -- `Result`, ranking и публикация итогов еще не реализованы. Документ описывает следующий этап React-контура PROCOLLAB: назначение экспертов на конкретные `Submission`, кабинет эксперта и управляемую отправку @@ -584,12 +584,12 @@ RFC не меняет: добавлять. 2. **Assignment service/API (реализовано).** Транзакционные manager permissions, list/create/revoke, history и tests. -3. **Expert Submission read API.** Изолированный queryset, PII-safe serializer, - criteria contract и object-level permission tests. -4. **Evaluation mutation API.** Draft create/update/submit, idempotency, - locking, ranges, completeness и throttling. -5. **Manager evaluation read API.** Просмотр форм/статусов своей Program без - редактирования экспертных scores. +3. **Expert Submission read API (реализовано).** Изолированный queryset, + PII-safe serializer, criteria contract и object-level permission tests. +4. **Evaluation mutation API (реализовано).** Draft create/update/submit, + idempotency, locking, ranges, completeness и throttling. +5. **Manager evaluation read API (реализовано).** Просмотр форм/статусов своей + Program без редактирования экспертных scores. 6. **React expert cabinet.** Список назначений, detail, autosave draft и финальная отправка. 7. **Publication and Result RFC.** Настройка видимости участнику, формула diff --git a/partner_programs/evaluation_urls.py b/partner_programs/evaluation_urls.py new file mode 100644 index 00000000..d9ad322f --- /dev/null +++ b/partner_programs/evaluation_urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from partner_programs.evaluation_views import ( + EvaluationDetailView, + EvaluationSubmitView, +) + +app_name = "evaluations" + +urlpatterns = [ + path("/", EvaluationDetailView.as_view(), name="detail"), + path( + "/submit/", + EvaluationSubmitView.as_view(), + name="submit", + ), +] diff --git a/partner_programs/evaluation_views.py b/partner_programs/evaluation_views.py new file mode 100644 index 00000000..92ad20cb --- /dev/null +++ b/partner_programs/evaluation_views.py @@ -0,0 +1,402 @@ +# Roadmap: DEV-050, DEV-051, DEV-052 +# Контур экспертного доступа к Submission и управления Evaluation. + +from drf_yasg import openapi +from drf_yasg.utils import no_body, swagger_auto_schema +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.pagination import PartnerProgramPagination +from partner_programs.permissions import IsAdminOrManagerOfProgram +from partner_programs.serializers.evaluations import ( + EvaluationDraftCreateSerializer, + EvaluationDraftUpdateSerializer, + EvaluationReadSerializer, + ExpertSubmissionDetailSerializer, + ExpertSubmissionFilterSerializer, + ExpertSubmissionListSerializer, + ManagerEvaluationFilterSerializer, + ManagerEvaluationSerializer, +) +from partner_programs.services.evaluations import ( + EvaluationAccessDeniedError, + EvaluationConflictError, + EvaluationNotFoundError, + EvaluationServiceError, + EvaluationValidationError, + create_or_get_draft_evaluation, + expert_submission_assignments, + get_expert_submission_detail, + get_my_evaluation, + get_visible_evaluation, + manager_evaluations_queryset, + submit_evaluation, + update_draft_evaluation, +) +from partner_programs.submission_assignment_views import ProgramPermissionMixin +from partner_programs.throttling import PatchOnlyScopedRateThrottle + + +PROGRAM_ID_PARAMETER = openapi.Parameter( + "program_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору программы.", + type=openapi.TYPE_INTEGER, +) +SUBMISSION_STATUS_PARAMETER = openapi.Parameter( + "submission_status", + openapi.IN_QUERY, + description="Фильтр по статусу Submission.", + type=openapi.TYPE_STRING, +) +EVALUATION_STATUS_PARAMETER = openapi.Parameter( + "evaluation_status", + openapi.IN_QUERY, + description="Фильтр по статусу Evaluation: draft, submitted или none.", + type=openapi.TYPE_STRING, +) +SUBMISSION_ID_PARAMETER = openapi.Parameter( + "submission_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору Submission.", + type=openapi.TYPE_INTEGER, +) +EXPERT_ID_PARAMETER = openapi.Parameter( + "expert_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору Expert.", + type=openapi.TYPE_INTEGER, +) +ASSIGNMENT_STATUS_PARAMETER = openapi.Parameter( + "assignment_status", + openapi.IN_QUERY, + description="Фильтр по статусу назначения.", + type=openapi.TYPE_STRING, +) +STAGE_KEY_PARAMETER = openapi.Parameter( + "stage_key", + openapi.IN_QUERY, + description="Фильтр по этапу Submission.", + type=openapi.TYPE_STRING, +) + + +def _domain_error_response(exc): + if isinstance(exc, EvaluationAccessDeniedError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + if isinstance(exc, EvaluationNotFoundError): + raise NotFound(exc.detail, code=exc.code) from exc + if isinstance(exc, EvaluationValidationError): + return Response( + {exc.field or "detail": [exc.detail]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, EvaluationConflictError): + return Response( + {"detail": exc.detail, "code": exc.code}, + status=status.HTTP_409_CONFLICT, + ) + raise exc + + +class ExpertSubmissionListView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = PartnerProgramPagination + + @swagger_auto_schema( + operation_description=( + "PII-safe список Submission, назначенных текущему эксперту. " + "Роль Expert без назначения не даёт доступ к решению." + ), + manual_parameters=[ + PROGRAM_ID_PARAMETER, + SUBMISSION_STATUS_PARAMETER, + EVALUATION_STATUS_PARAMETER, + ], + responses={ + 200: ExpertSubmissionListSerializer(many=True), + 400: "Некорректный фильтр.", + 401: "Требуется авторизация.", + 403: "У пользователя нет профиля Expert.", + }, + ) + def get(self, request): + filter_serializer = ExpertSubmissionFilterSerializer(data=request.query_params) + filter_serializer.is_valid(raise_exception=True) + try: + queryset = expert_submission_assignments(user=request.user) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + + filters = filter_serializer.validated_data + if "program_id" in filters: + queryset = queryset.filter(submission__program_id=filters["program_id"]) + if "submission_status" in filters: + queryset = queryset.filter(submission__status=filters["submission_status"]) + if "evaluation_status" in filters: + evaluation_status = filters["evaluation_status"] + if evaluation_status == "none": + queryset = queryset.filter(my_evaluation_id__isnull=True) + else: + queryset = queryset.filter(my_evaluation_status=evaluation_status) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response( + ExpertSubmissionListSerializer(page, many=True).data + ) + + +class ExpertSubmissionDetailView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=( + "PII-safe detail назначенной Submission. form_data участника " + "намеренно не включается; возвращаются description и отдельные links." + ), + responses={ + 200: ExpertSubmissionDetailSerializer, + 401: "Требуется авторизация.", + 404: "Submission не назначена текущему эксперту.", + }, + ) + def get(self, request, submission_id): + try: + result = get_expert_submission_detail( + submission_id=submission_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(ExpertSubmissionDetailSerializer(result).data) + + +class MyEvaluationView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=( + "Возвращает существующую Evaluation текущего назначенного эксперта. " + "GET никогда не создаёт черновик." + ), + responses={ + 200: EvaluationReadSerializer, + 401: "Требуется авторизация.", + 404: "Evaluation не существует или Submission не назначена.", + }, + ) + def get(self, request, submission_id): + try: + evaluation = get_my_evaluation( + submission_id=submission_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationCreateView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "evaluation_create" + + @swagger_auto_schema( + operation_description=( + "Создаёт draft Evaluation для assigned-назначения. Первый запрос " + "возвращает 201; повторный POST возвращает тот же draft с 200 и " + "не изменяет его. Для submitted Evaluation возвращается 409." + ), + request_body=EvaluationDraftCreateSerializer, + responses={ + 200: EvaluationReadSerializer, + 201: EvaluationReadSerializer, + 400: "Некорректные Criteria или значения.", + 401: "Требуется авторизация.", + 404: "Submission не назначена.", + 409: "Конфликт статуса назначения, Submission или Evaluation.", + 429: "Превышен evaluation_create throttle.", + }, + ) + def post(self, request, submission_id): + serializer = EvaluationDraftCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = create_or_get_draft_evaluation( + submission_id=submission_id, + user=request.user, + comment=serializer.validated_data["comment"], + scores=serializer.validated_data["scores"], + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + EvaluationReadSerializer(result.evaluation).data, + status=response_status, + ) + + +class EvaluationDetailView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PatchOnlyScopedRateThrottle] + throttle_scope = "evaluation_update" + + @swagger_auto_schema( + operation_description=( + "Read-only detail для владельца-эксперта, manager программы и staff." + ), + responses={ + 200: EvaluationReadSerializer, + 401: "Требуется авторизация.", + 404: "Evaluation скрыта или не существует.", + }, + ) + def get(self, request, evaluation_id): + try: + evaluation = get_visible_evaluation( + evaluation_id=evaluation_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + @swagger_auto_schema( + operation_description=( + "Autosave draft Evaluation владельцем. Если scores передан, набор " + "заменяется целиком в одной транзакции. Submitted Evaluation " + "неизменяема." + ), + request_body=EvaluationDraftUpdateSerializer, + responses={ + 200: EvaluationReadSerializer, + 400: "Некорректные Criteria или значения.", + 401: "Требуется авторизация.", + 404: "Чужая или скрытая Evaluation.", + 409: "Evaluation или assignment недоступны для изменения.", + 429: "Превышен evaluation_update throttle.", + }, + ) + def patch(self, request, evaluation_id): + serializer = EvaluationDraftUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + evaluation = update_draft_evaluation( + evaluation_id=evaluation_id, + user=request.user, + comment_supplied="comment" in serializer.validated_data, + comment=serializer.validated_data.get("comment", ""), + scores_supplied="scores" in serializer.validated_data, + scores=serializer.validated_data.get("scores"), + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationSubmitView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "evaluation_submit" + + @swagger_auto_schema( + operation_description=( + "Атомарно отправляет полную Evaluation и завершает assignment. " + "Повторный submit идемпотентен и не меняет submitted_at." + ), + request_body=no_body, + responses={ + 200: EvaluationReadSerializer, + 400: "Заполнены не все Criteria или значения невалидны.", + 401: "Требуется авторизация.", + 404: "Чужая или скрытая Evaluation.", + 409: "Assignment или Submission недоступны.", + 429: "Превышен evaluation_submit throttle.", + }, + ) + def post(self, request, evaluation_id): + try: + evaluation = submit_evaluation( + evaluation_id=evaluation_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class ProgramEvaluationListView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + pagination_class = PartnerProgramPagination + + @swagger_auto_schema( + operation_description=( + "Read-only список Evaluation указанной программы для manager и staff." + ), + manual_parameters=[ + SUBMISSION_ID_PARAMETER, + EXPERT_ID_PARAMETER, + EVALUATION_STATUS_PARAMETER, + ASSIGNMENT_STATUS_PARAMETER, + STAGE_KEY_PARAMETER, + ], + responses={ + 200: ManagerEvaluationSerializer(many=True), + 400: "Некорректный фильтр.", + 401: "Требуется авторизация.", + 403: "Нет прав manager этой программы.", + 404: "Программа не найдена.", + }, + ) + def get(self, request, program_id): + serializer = ManagerEvaluationFilterSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + queryset = manager_evaluations_queryset(program=self.program) + filters = serializer.validated_data + if "submission_id" in filters: + queryset = queryset.filter(submission_id=filters["submission_id"]) + if "expert_id" in filters: + queryset = queryset.filter(expert_id=filters["expert_id"]) + if "evaluation_status" in filters: + queryset = queryset.filter(status=filters["evaluation_status"]) + if "assignment_status" in filters: + queryset = queryset.filter(assignment_status=filters["assignment_status"]) + if "stage_key" in filters: + queryset = queryset.filter(submission__stage_key=filters["stage_key"]) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response( + ManagerEvaluationSerializer(page, many=True).data + ) + + +class ProgramEvaluationDetailView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + + @swagger_auto_schema( + operation_description=( + "Read-only Evaluation detail для manager указанной программы и staff." + ), + responses={ + 200: ManagerEvaluationSerializer, + 401: "Требуется авторизация.", + 403: "Нет прав manager этой программы.", + 404: "Программа или Evaluation не найдена.", + }, + ) + def get(self, request, program_id, evaluation_id): + evaluation = get_object_or_404( + manager_evaluations_queryset(program=self.program), + pk=evaluation_id, + ) + return Response(ManagerEvaluationSerializer(evaluation).data) diff --git a/partner_programs/expert_urls.py b/partner_programs/expert_urls.py new file mode 100644 index 00000000..639c043e --- /dev/null +++ b/partner_programs/expert_urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from partner_programs.evaluation_views import ( + ExpertSubmissionDetailView, + ExpertSubmissionListView, +) + +app_name = "expert_submissions" + +urlpatterns = [ + path("submissions/", ExpertSubmissionListView.as_view(), name="list"), + path( + "submissions//", + ExpertSubmissionDetailView.as_view(), + name="detail", + ), +] diff --git a/partner_programs/serializers/evaluations.py b/partner_programs/serializers/evaluations.py new file mode 100644 index 00000000..1458457d --- /dev/null +++ b/partner_programs/serializers/evaluations.py @@ -0,0 +1,295 @@ +# Roadmap: DEV-050, DEV-051, DEV-052 +# PII-safe контракты решений и экспертных оценок. + +from rest_framework import serializers + +from partner_programs.models import ( + Evaluation, + EvaluationScore, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from project_rates.models import Criteria +from users.models import Expert + + +class EvaluationScoreWriteSerializer(serializers.Serializer): + criterion_id = serializers.IntegerField(min_value=1) + value = serializers.DecimalField( + max_digits=18, + decimal_places=6, + coerce_to_string=False, + ) + + +class EvaluationDraftCreateSerializer(serializers.Serializer): + comment = serializers.CharField( + required=False, + allow_blank=True, + default="", + ) + scores = EvaluationScoreWriteSerializer( + many=True, + required=False, + default=list, + ) + + +class EvaluationDraftUpdateSerializer(serializers.Serializer): + comment = serializers.CharField(required=False, allow_blank=True) + scores = EvaluationScoreWriteSerializer(many=True, required=False) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError("Передайте comment или scores.") + return attrs + + +class ProgramSummarySerializer(serializers.ModelSerializer): + class Meta: + model = PartnerProgram + fields = ("id", "name") + read_only_fields = fields + + +class EvaluationExpertSummarySerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(read_only=True) + first_name = serializers.CharField(source="user.first_name", read_only=True) + last_name = serializers.CharField(source="user.last_name", read_only=True) + + class Meta: + model = Expert + fields = ("id", "user_id", "first_name", "last_name") + read_only_fields = fields + + +class EvaluationCriterionSerializer(serializers.ModelSerializer): + class Meta: + model = Criteria + fields = ( + "id", + "name", + "description", + "type", + "min_value", + "max_value", + ) + read_only_fields = fields + + +class EvaluationScoreReadSerializer(serializers.ModelSerializer): + criterion_id = serializers.IntegerField(read_only=True) + value = serializers.DecimalField( + max_digits=18, + decimal_places=6, + read_only=True, + ) + + class Meta: + model = EvaluationScore + fields = ( + "id", + "criterion_id", + "value", + "criterion_name", + "criterion_type", + "min_value", + "max_value", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class EvaluationReadSerializer(serializers.ModelSerializer): + submission_id = serializers.IntegerField(read_only=True) + expert = EvaluationExpertSummarySerializer(read_only=True) + scores = EvaluationScoreReadSerializer(many=True, read_only=True) + + class Meta: + model = Evaluation + fields = ( + "id", + "submission_id", + "expert", + "status", + "comment", + "scores", + "total_score", + "submitted_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class ExpertAssignmentSummarySerializer(serializers.ModelSerializer): + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "status", + "assigned_at", + "completed_at", + ) + read_only_fields = fields + + +class ExpertEvaluationSummarySerializer(serializers.Serializer): + id = serializers.IntegerField(allow_null=True, read_only=True) + status = serializers.CharField(allow_null=True, read_only=True) + updated_at = serializers.DateTimeField(allow_null=True, read_only=True) + submitted_at = serializers.DateTimeField(allow_null=True, read_only=True) + + +class ExpertSubmissionListSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="submission_id", read_only=True) + program = ProgramSummarySerializer(source="submission.program", read_only=True) + title = serializers.CharField(source="submission.title", read_only=True) + status = serializers.CharField( + source="submission.status", + read_only=True, + ) + stage_key = serializers.CharField(source="submission.stage_key", read_only=True) + version = serializers.IntegerField(source="submission.version", read_only=True) + submitted_at = serializers.DateTimeField( + source="submission.submitted_at", + read_only=True, + ) + assignment = ExpertAssignmentSummarySerializer(source="*", read_only=True) + my_evaluation = serializers.SerializerMethodField() + + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "program", + "title", + "status", + "stage_key", + "version", + "submitted_at", + "assignment", + "my_evaluation", + ) + read_only_fields = fields + + def get_my_evaluation(self, assignment): + if assignment.my_evaluation_id is None: + return None + return { + "id": assignment.my_evaluation_id, + "status": assignment.my_evaluation_status, + "updated_at": assignment.my_evaluation_updated_at, + "submitted_at": assignment.my_evaluation_submitted_at, + } + + +class ExpertSubmissionDetailSerializer(serializers.Serializer): + id = serializers.IntegerField(source="submission.id", read_only=True) + program = ProgramSummarySerializer(source="submission.program", read_only=True) + title = serializers.CharField(source="submission.title", read_only=True) + description = serializers.CharField( + source="submission.description", + read_only=True, + ) + links = serializers.JSONField(source="submission.links", read_only=True) + status = serializers.CharField(source="submission.status", read_only=True) + stage_key = serializers.CharField( + source="submission.stage_key", + read_only=True, + ) + version = serializers.IntegerField(source="submission.version", read_only=True) + submitted_at = serializers.DateTimeField( + source="submission.submitted_at", + read_only=True, + ) + assignment = ExpertAssignmentSummarySerializer(read_only=True) + my_evaluation = EvaluationReadSerializer( + source="evaluation", + allow_null=True, + read_only=True, + ) + criteria = EvaluationCriterionSerializer(many=True, read_only=True) + + +class ExpertSubmissionFilterSerializer(serializers.Serializer): + program_id = serializers.IntegerField(min_value=1, required=False) + submission_status = serializers.ChoiceField( + choices=Submission.STATUS_CHOICES, + required=False, + ) + evaluation_status = serializers.ChoiceField( + choices=( + (Evaluation.STATUS_DRAFT, Evaluation.STATUS_DRAFT), + (Evaluation.STATUS_SUBMITTED, Evaluation.STATUS_SUBMITTED), + ("none", "none"), + ), + required=False, + ) + + +class ManagerEvaluationFilterSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, required=False) + expert_id = serializers.IntegerField(min_value=1, required=False) + evaluation_status = serializers.ChoiceField( + choices=Evaluation.STATUS_CHOICES, + required=False, + ) + assignment_status = serializers.ChoiceField( + choices=SubmissionExpertAssignment.STATUS_CHOICES, + required=False, + ) + stage_key = serializers.CharField(max_length=128, required=False) + + +class ManagerSubmissionSummarySerializer(serializers.ModelSerializer): + program = ProgramSummarySerializer(read_only=True) + + class Meta: + model = Submission + fields = ( + "id", + "program", + "title", + "status", + "stage_key", + "version", + "submitted_at", + ) + read_only_fields = fields + + +class ManagerEvaluationSerializer(serializers.ModelSerializer): + submission = ManagerSubmissionSummarySerializer(read_only=True) + expert = EvaluationExpertSummarySerializer(read_only=True) + assignment = serializers.SerializerMethodField() + scores = EvaluationScoreReadSerializer(many=True, read_only=True) + + class Meta: + model = Evaluation + fields = ( + "id", + "status", + "submission", + "expert", + "assignment", + "scores", + "comment", + "total_score", + "submitted_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + def get_assignment(self, evaluation): + if not hasattr(evaluation, "assignment_id") or evaluation.assignment_id is None: + return None + return { + "id": evaluation.assignment_id, + "status": evaluation.assignment_status, + "assigned_at": evaluation.assignment_assigned_at, + "completed_at": evaluation.assignment_completed_at, + } diff --git a/partner_programs/serializers/submission_assignments.py b/partner_programs/serializers/submission_assignments.py index db91b690..5eb0844d 100644 --- a/partner_programs/serializers/submission_assignments.py +++ b/partner_programs/serializers/submission_assignments.py @@ -44,6 +44,7 @@ class SubmissionAssignmentReadSerializer(serializers.ModelSerializer): assigned_by_id = serializers.IntegerField(read_only=True) revoked_by_id = serializers.IntegerField(read_only=True) evaluation_status = serializers.SerializerMethodField() + evaluation = serializers.SerializerMethodField() class Meta: model = SubmissionExpertAssignment @@ -59,6 +60,7 @@ class Meta: "revoked_at", "revoke_reason", "evaluation_status", + "evaluation", ) read_only_fields = fields @@ -74,6 +76,33 @@ def get_evaluation_status(self, assignment): .first() ) + def get_evaluation(self, assignment): + if not hasattr(assignment, "annotated_evaluation_id"): + evaluation = ( + Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + ) + .values( + "id", + "status", + "updated_at", + "submitted_at", + "total_score", + ) + .first() + ) + return evaluation + if assignment.annotated_evaluation_id is None: + return None + return { + "id": assignment.annotated_evaluation_id, + "status": assignment.annotated_evaluation_status, + "updated_at": assignment.annotated_evaluation_updated_at, + "submitted_at": assignment.annotated_evaluation_submitted_at, + "total_score": assignment.annotated_evaluation_total_score, + } + class SubmissionAssignmentCreateSerializer(serializers.Serializer): submission_id = serializers.IntegerField(min_value=1, write_only=True) diff --git a/partner_programs/services/evaluations.py b/partner_programs/services/evaluations.py new file mode 100644 index 00000000..cae4ad12 --- /dev/null +++ b/partner_programs/services/evaluations.py @@ -0,0 +1,612 @@ +# Roadmap: DEV-050, DEV-051, DEV-052 +# Контур экспертного доступа к Submission и управления Evaluation. + +from dataclasses import dataclass +from decimal import Decimal + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.db.models import OuterRef, Prefetch, Subquery +from django.utils import timezone + +from partner_programs.models import ( + Evaluation, + EvaluationScore, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from project_rates.models import Criteria +from users.models import Expert + + +class EvaluationServiceError(Exception): + code = "evaluation_error" + default_detail = "Не удалось выполнить операцию с оценкой." + field = None + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field if field is not None else self.field + super().__init__(self.detail) + + +class EvaluationAccessDeniedError(EvaluationServiceError): + code = "evaluation_access_denied" + default_detail = "Доступ к экспертному контуру запрещён." + + +class EvaluationNotFoundError(EvaluationServiceError): + code = "evaluation_not_found" + default_detail = "Оценка или назначенное решение не найдено." + + +class EvaluationValidationError(EvaluationServiceError): + code = "evaluation_validation" + + +class EvaluationConflictError(EvaluationServiceError): + code = "evaluation_conflict" + + +class AssignmentUnavailableError(EvaluationConflictError): + code = "assignment_unavailable" + default_detail = "Активное назначение эксперта недоступно." + + +class SubmissionUnavailableError(EvaluationConflictError): + code = "submission_unavailable" + default_detail = "Оценивать можно только отправленное или финальное решение." + + +class EvaluationSubmittedError(EvaluationConflictError): + code = "evaluation_submitted" + default_detail = "Отправленная оценка недоступна для изменения." + + +@dataclass(frozen=True) +class EvaluationCreationResult: + evaluation: Evaluation + created: bool + + +@dataclass(frozen=True) +class ExpertSubmissionDetailResult: + submission: Submission + assignment: SubmissionExpertAssignment + evaluation: Evaluation | None + criteria: list[Criteria] + + +def get_numeric_criteria(program: PartnerProgram): + return Criteria.objects.filter( + partner_program=program, + type__in=EvaluationScore.NUMERIC_CRITERION_TYPES, + ).order_by("id") + + +def _expert_for_user(user, *, list_access=False): + try: + return user.expert + except Expert.DoesNotExist as exc: + error_class = ( + EvaluationAccessDeniedError if list_access else EvaluationNotFoundError + ) + raise error_class() from exc + + +def _require_expert_membership(expert: Expert, program: PartnerProgram): + if not expert.programs.filter(pk=program.pk).exists(): + raise EvaluationNotFoundError() + + +def _require_submission_status(submission: Submission): + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionUnavailableError() + + +def _assignment_queryset(*, submission_id, expert_id, for_update=False): + queryset = SubmissionExpertAssignment.objects.select_related( + "submission", + "submission__program", + "expert", + ).filter( + submission_id=submission_id, + expert_id=expert_id, + ) + if for_update: + queryset = queryset.select_for_update() + return queryset.order_by("-created_at", "-id") + + +def _require_assigned_episode(*, submission_id, expert_id, for_update=False): + queryset = _assignment_queryset( + submission_id=submission_id, + expert_id=expert_id, + for_update=for_update, + ) + assignment = queryset.filter( + status=SubmissionExpertAssignment.STATUS_ASSIGNED + ).first() + if assignment is not None: + return assignment + if queryset.exists(): + raise AssignmentUnavailableError() + raise EvaluationNotFoundError() + + +def _active_assignment(*, submission_id, expert_id): + return ( + _assignment_queryset( + submission_id=submission_id, + expert_id=expert_id, + ) + .filter(status__in=SubmissionExpertAssignment.ACTIVE_STATUSES) + .first() + ) + + +def expert_submission_assignments(*, user): + expert = _expert_for_user(user, list_access=True) + evaluation = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=expert.pk, + ) + return ( + SubmissionExpertAssignment.objects.filter( + expert=expert, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + submission__program__experts=expert, + ) + .select_related( + "submission", + "submission__program", + ) + .annotate( + my_evaluation_id=Subquery(evaluation.values("id")[:1]), + my_evaluation_status=Subquery(evaluation.values("status")[:1]), + my_evaluation_updated_at=Subquery(evaluation.values("updated_at")[:1]), + my_evaluation_submitted_at=Subquery(evaluation.values("submitted_at")[:1]), + ) + .order_by("-assigned_at", "-id") + ) + + +def get_expert_submission_detail(*, submission_id, user): + submission = ( + Submission.objects.select_related("program").filter(pk=submission_id).first() + ) + if submission is None: + raise EvaluationNotFoundError() + + if user.is_staff or user.is_superuser: + assignment = ( + SubmissionExpertAssignment.objects.select_related("expert") + .filter( + submission=submission, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ) + .order_by("-created_at", "-id") + .first() + ) + else: + expert = _expert_for_user(user) + assignment = _active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if assignment is not None: + _require_expert_membership(expert, submission.program) + if assignment is None: + raise EvaluationNotFoundError() + + evaluation = ( + Evaluation.objects.prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter( + submission=submission, + expert_id=assignment.expert_id, + ) + .first() + ) + return ExpertSubmissionDetailResult( + submission=submission, + assignment=assignment, + evaluation=evaluation, + criteria=list(get_numeric_criteria(submission.program)), + ) + + +def get_my_evaluation(*, submission_id, user): + expert = _expert_for_user(user) + assignment = _active_assignment( + submission_id=submission_id, + expert_id=expert.pk, + ) + if assignment is None: + raise EvaluationNotFoundError() + _require_expert_membership(expert, assignment.submission.program) + evaluation = ( + Evaluation.objects.select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter( + submission_id=submission_id, + expert=expert, + ) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + return evaluation + + +def _validated_scores(*, program, scores): + criterion_ids = [item["criterion_id"] for item in scores] + if len(criterion_ids) != len(set(criterion_ids)): + raise EvaluationValidationError( + "Критерии в scores не должны повторяться.", + field="scores", + ) + + criteria = { + criterion.pk: criterion + for criterion in Criteria.objects.filter(pk__in=criterion_ids) + } + validated = [] + for item in scores: + criterion = criteria.get(item["criterion_id"]) + if criterion is None: + raise EvaluationValidationError( + "Указан неизвестный критерий.", + field="scores", + ) + if criterion.partner_program_id != program.pk: + raise EvaluationValidationError( + "Критерий относится к другой программе.", + field="scores", + ) + if criterion.type not in EvaluationScore.NUMERIC_CRITERION_TYPES: + raise EvaluationValidationError( + "В scores разрешены только числовые критерии.", + field="scores", + ) + + value = item["value"] + if criterion.type == "int" and value != value.to_integral_value(): + raise EvaluationValidationError( + "Для целочисленного критерия требуется целое значение.", + field="scores", + ) + if criterion.min_value is not None and value < Decimal(str(criterion.min_value)): + raise EvaluationValidationError( + f"Значение критерия {criterion.pk} меньше допустимого минимума.", + field="scores", + ) + if criterion.max_value is not None and value > Decimal(str(criterion.max_value)): + raise EvaluationValidationError( + f"Значение критерия {criterion.pk} больше допустимого максимума.", + field="scores", + ) + validated.append((criterion, value)) + return validated + + +def _replace_scores(*, evaluation, validated_scores): + evaluation.scores.all().delete() + for criterion, value in validated_scores: + EvaluationScore.objects.create( + evaluation=evaluation, + criterion=criterion, + value=value, + ) + + +def _existing_creation_result(evaluation): + if evaluation.status == Evaluation.STATUS_SUBMITTED: + raise EvaluationSubmittedError() + return EvaluationCreationResult( + evaluation=evaluation, + created=False, + ) + + +def create_or_get_draft_evaluation( + *, + submission_id, + user, + comment="", + scores=None, +): + expert = _expert_for_user(user) + with transaction.atomic(): + assignment = _require_assigned_episode( + submission_id=submission_id, + expert_id=expert.pk, + for_update=True, + ) + submission = assignment.submission + _require_expert_membership(expert, submission.program) + _require_submission_status(submission) + + existing = ( + Evaluation.objects.select_for_update() + .filter(submission=submission, expert=expert) + .first() + ) + if existing is not None: + return _existing_creation_result(existing) + + validated_scores = _validated_scores( + program=submission.program, + scores=scores or [], + ) + try: + with transaction.atomic(): + evaluation = Evaluation.objects.create( + submission=submission, + expert=expert, + comment=comment, + ) + except (IntegrityError, DjangoValidationError) as exc: + existing = ( + Evaluation.objects.select_for_update() + .filter(submission=submission, expert=expert) + .first() + ) + if existing is not None: + return _existing_creation_result(existing) + raise EvaluationConflictError( + "Оценка изменилась конкурентно. Повторите запрос." + ) from exc + + _replace_scores( + evaluation=evaluation, + validated_scores=validated_scores, + ) + return EvaluationCreationResult( + evaluation=evaluation, + created=True, + ) + + +def _evaluation_identity_for_owner(*, evaluation_id, user): + expert = _expert_for_user(user) + evaluation = ( + Evaluation.objects.select_related("submission", "submission__program") + .filter(pk=evaluation_id, expert=expert) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + _require_expert_membership(expert, evaluation.submission.program) + return evaluation, expert + + +def update_draft_evaluation( + *, + evaluation_id, + user, + comment_supplied=False, + comment="", + scores_supplied=False, + scores=None, +): + identity, expert = _evaluation_identity_for_owner( + evaluation_id=evaluation_id, + user=user, + ) + with transaction.atomic(): + _require_assigned_episode( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ) + evaluation = ( + Evaluation.objects.select_for_update() + .select_related("submission", "submission__program") + .get(pk=identity.pk) + ) + if evaluation.status == Evaluation.STATUS_SUBMITTED: + raise EvaluationSubmittedError() + _require_submission_status(evaluation.submission) + + if scores_supplied: + validated_scores = _validated_scores( + program=evaluation.submission.program, + scores=scores or [], + ) + _replace_scores( + evaluation=evaluation, + validated_scores=validated_scores, + ) + if comment_supplied: + evaluation.comment = comment + if comment_supplied or scores_supplied: + evaluation.save(update_fields=["comment", "updated_at"]) + + return evaluation + + +def _validate_complete_evaluation(*, evaluation): + criteria = list(get_numeric_criteria(evaluation.submission.program)) + criteria_by_id = {criterion.pk: criterion for criterion in criteria} + scores = list(evaluation.scores.select_related("criterion").all()) + score_ids = [score.criterion_id for score in scores] + if len(score_ids) != len(set(score_ids)): + raise EvaluationValidationError( + "Критерии в оценке не должны повторяться.", + field="scores", + ) + if set(score_ids) != set(criteria_by_id): + raise EvaluationValidationError( + "Перед отправкой заполните все числовые критерии программы.", + field="scores", + ) + _validated_scores( + program=evaluation.submission.program, + scores=[ + { + "criterion_id": score.criterion_id, + "value": score.value, + } + for score in scores + ], + ) + + +def submit_evaluation(*, evaluation_id, user): + identity, expert = _evaluation_identity_for_owner( + evaluation_id=evaluation_id, + user=user, + ) + with transaction.atomic(): + assignment = ( + _assignment_queryset( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ) + .filter(status__in=SubmissionExpertAssignment.ACTIVE_STATUSES) + .first() + ) + if assignment is None: + if _assignment_queryset( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ).exists(): + raise AssignmentUnavailableError() + raise EvaluationNotFoundError() + + evaluation = ( + Evaluation.objects.select_for_update() + .select_related("submission", "submission__program") + .get(pk=identity.pk) + ) + if evaluation.status == Evaluation.STATUS_SUBMITTED: + return evaluation + if assignment.status != SubmissionExpertAssignment.STATUS_ASSIGNED: + raise AssignmentUnavailableError() + _require_submission_status(evaluation.submission) + _validate_complete_evaluation(evaluation=evaluation) + + now = timezone.now() + evaluation.status = Evaluation.STATUS_SUBMITTED + evaluation.submitted_at = now + evaluation.total_score = None + evaluation.save( + update_fields=[ + "status", + "submitted_at", + "total_score", + "updated_at", + ] + ) + + assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + assignment.completed_at = now + assignment.save( + update_fields=[ + "status", + "completed_at", + "updated_at", + ] + ) + return evaluation + + +def get_visible_evaluation(*, evaluation_id, user): + evaluation = ( + Evaluation.objects.select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter(pk=evaluation_id) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + if user.is_staff or user.is_superuser: + return evaluation + try: + expert = user.expert + except Expert.DoesNotExist: + expert = None + if ( + expert is not None + and evaluation.expert_id == expert.pk + and expert.programs.filter(pk=evaluation.submission.program_id).exists() + and _active_assignment( + submission_id=evaluation.submission_id, + expert_id=expert.pk, + ) + is not None + ): + return evaluation + if evaluation.submission.program.is_manager(user): + return evaluation + raise EvaluationNotFoundError() + + +def manager_evaluations_queryset(*, program): + latest_assignment = SubmissionExpertAssignment.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ).order_by("-created_at", "-id") + return ( + Evaluation.objects.filter(submission__program=program) + .select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .annotate( + assignment_id=Subquery(latest_assignment.values("id")[:1]), + assignment_status=Subquery(latest_assignment.values("status")[:1]), + assignment_assigned_at=Subquery(latest_assignment.values("assigned_at")[:1]), + assignment_completed_at=Subquery( + latest_assignment.values("completed_at")[:1] + ), + ) + .order_by("-updated_at", "-id") + ) diff --git a/partner_programs/submission_assignment_views.py b/partner_programs/submission_assignment_views.py index e9d2c9b5..2e9bae44 100644 --- a/partner_programs/submission_assignment_views.py +++ b/partner_programs/submission_assignment_views.py @@ -33,6 +33,10 @@ def _assignment_queryset(): submission_id=OuterRef("submission_id"), expert_id=OuterRef("expert_id"), ).values("status")[:1] + evaluation = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ) return SubmissionExpertAssignment.objects.select_related( "submission", "expert", @@ -41,6 +45,10 @@ def _assignment_queryset(): "revoked_by", ).annotate( annotated_evaluation_status=Subquery(evaluation_status), + annotated_evaluation_id=Subquery(evaluation.values("id")[:1]), + annotated_evaluation_updated_at=Subquery(evaluation.values("updated_at")[:1]), + annotated_evaluation_submitted_at=Subquery(evaluation.values("submitted_at")[:1]), + annotated_evaluation_total_score=Subquery(evaluation.values("total_score")[:1]), ) diff --git a/partner_programs/submission_urls.py b/partner_programs/submission_urls.py index 0a436621..d708ba8d 100644 --- a/partner_programs/submission_urls.py +++ b/partner_programs/submission_urls.py @@ -1,5 +1,6 @@ from django.urls import path +from partner_programs.evaluation_views import EvaluationCreateView, MyEvaluationView from partner_programs.submission_views import ( SubmissionCancelView, SubmissionDetailView, @@ -9,6 +10,16 @@ app_name = "submissions" urlpatterns = [ + path( + "/evaluations/my/", + MyEvaluationView.as_view(), + name="my-evaluation", + ), + path( + "/evaluations/", + EvaluationCreateView.as_view(), + name="evaluation-create", + ), path("/", SubmissionDetailView.as_view(), name="detail"), path( "/submit/", diff --git a/partner_programs/tests/test_expert_evaluation_api.py b/partner_programs/tests/test_expert_evaluation_api.py new file mode 100644 index 00000000..631818de --- /dev/null +++ b/partner_programs/tests/test_expert_evaluation_api.py @@ -0,0 +1,978 @@ +# Roadmap: DEV-050, DEV-051, DEV-052 +# Проверки экспертного доступа, autosave, submit и manager read-only API. + +from decimal import Decimal +from threading import Barrier, Thread + +from django.conf import settings +from django.core.cache import cache +from django.db import close_old_connections +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + Submission, + SubmissionExpertAssignment, +) +from partner_programs.services.evaluations import submit_evaluation +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class ExpertEvaluationAPITestCase(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.manager = create_user(prefix="evaluation-api-manager") + self.other_manager = create_user(prefix="evaluation-api-other-manager") + self.staff = create_user(prefix="evaluation-api-staff", is_staff=True) + self.participant = create_user(prefix="evaluation-api-participant") + self.program = create_partner_program() + self.other_program = create_partner_program() + self.program.managers.add(self.manager) + self.other_program.managers.add(self.other_manager) + self.expert_user = create_rate_expert( + prefix="evaluation-api-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + self.other_expert_user = create_rate_expert( + prefix="evaluation-api-other-expert", + program=self.program, + ) + self.other_expert = self.other_expert_user.expert + self.foreign_expert_user = create_rate_expert( + prefix="evaluation-api-foreign-expert", + program=self.other_program, + ) + self.foreign_expert = self.foreign_expert_user.expert + self.submission = self.create_submission(participant=self.participant) + self.assignment = self.create_assignment() + self.int_criterion = create_rate_criteria( + self.program, + name="Impact", + type="int", + min_value=1, + max_value=10, + ) + self.float_criterion = create_rate_criteria( + self.program, + name="Feasibility", + type="float", + min_value=0.5, + max_value=5.5, + ) + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.expert_user) + + def create_submission( + self, + *, + program=None, + participant=None, + status=Submission.STATUS_SUBMITTED, + title="Expert solution", + form_data=None, + links=None, + ): + program = program or self.program + participant = participant or create_user( + prefix="evaluation-submission-participant" + ) + application = Application.objects.create( + program=program, + user=participant, + created_by=participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + form_data={"registration_secret": "private"}, + ) + return Submission.objects.create( + application=application, + program=program, + submitted_by=participant, + title=title, + description="Solution description", + form_data=form_data or {"solution_secret": "private"}, + links=links or ["https://example.com/solution"], + status=status, + submitted_at=( + timezone.now() + if status in (Submission.STATUS_SUBMITTED, Submission.STATUS_FINAL) + else None + ), + ) + + def create_assignment(self, *, submission=None, expert=None, **overrides): + values = { + "submission": submission or self.submission, + "expert": expert or self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def create_evaluation(self, *, submission=None, expert=None, **overrides): + values = { + "submission": submission or self.submission, + "expert": expert or self.expert, + } + values.update(overrides) + return Evaluation.objects.create(**values) + + def score_payload(self, *, int_value="8", float_value="4.5"): + return [ + {"criterion_id": self.int_criterion.pk, "value": int_value}, + {"criterion_id": self.float_criterion.pk, "value": float_value}, + ] + + +class ExpertSubmissionAPITests(ExpertEvaluationAPITestCase): + def test_list_contains_only_current_expert_assignments(self): + other_submission = self.create_submission(title="Other expert solution") + self.create_assignment( + submission=other_submission, + expert=self.other_expert, + ) + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["id"], self.submission.pk) + + def test_completed_assignment_remains_visible(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + self.assignment.completed_at = timezone.now() + self.assignment.save() + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["results"][0]["assignment"]["status"], + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_unassigned_and_foreign_submissions_are_hidden(self): + self.create_submission(title="Unassigned") + foreign_submission = self.create_submission( + program=self.other_program, + title="Foreign", + ) + self.create_assignment( + submission=foreign_submission, + expert=self.foreign_expert, + assigned_by=self.other_manager, + ) + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_and_participant_cannot_use_expert_list(self): + for user in (self.manager, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get("/expert/submissions/") + self.assertEqual(response.status_code, 403) + + def test_list_filters_program_submission_and_evaluation_status(self): + evaluation = self.create_evaluation() + other_submission = self.create_submission(status=Submission.STATUS_FINAL) + self.create_assignment(submission=other_submission) + self.authenticate() + + response = self.client.get( + "/expert/submissions/", + { + "program_id": self.program.pk, + "submission_status": Submission.STATUS_SUBMITTED, + "evaluation_status": Evaluation.STATUS_DRAFT, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [evaluation.submission_id], + ) + + def test_list_supports_limit_offset_pagination(self): + for index in range(11): + submission = self.create_submission(title=f"Solution {index}") + self.create_assignment(submission=submission) + self.authenticate() + + first = self.client.get("/expert/submissions/") + second = self.client.get("/expert/submissions/", {"offset": 10}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(first.data["count"], 12) + self.assertEqual(len(first.data["results"]), 10) + self.assertEqual(second.status_code, 200) + self.assertEqual(len(second.data["results"]), 2) + + def test_detail_returns_safe_solution_fields_and_numeric_criteria(self): + create_rate_criteria(self.program, name="Comment", type="str") + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["description"], "Solution description") + self.assertEqual(response.data["links"], ["https://example.com/solution"]) + self.assertEqual( + {item["id"] for item in response.data["criteria"]}, + {self.int_criterion.pk, self.float_criterion.pk}, + ) + + def test_detail_does_not_expose_participant_pii_or_form_data(self): + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + forbidden = { + "application", + "form_data", + "submitted_by", + "user", + "created_by", + "email", + "phone", + "team", + "team_members", + } + self.assertFalse(forbidden.intersection(response.data)) + self.assertNotIn("registration_secret", str(response.data)) + self.assertNotIn("solution_secret", str(response.data)) + self.assertNotIn(self.participant.email, str(response.data)) + + def test_unassigned_expert_gets_404_for_detail(self): + self.authenticate(self.other_expert_user) + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 404) + + def test_assignment_without_program_membership_does_not_grant_access(self): + self.create_assignment(expert=self.foreign_expert) + evaluation = self.create_evaluation(expert=self.foreign_expert) + self.authenticate(self.foreign_expert_user) + + list_response = self.client.get("/expert/submissions/") + detail_response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + evaluation_response = self.client.get(f"/evaluations/{evaluation.pk}/") + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data["count"], 0) + self.assertEqual(detail_response.status_code, 404) + self.assertEqual(evaluation_response.status_code, 404) + + def test_detail_does_not_expose_other_experts_evaluation(self): + self.create_assignment(expert=self.other_expert) + other_evaluation = self.create_evaluation(expert=self.other_expert) + other_evaluation.comment = "Private expert comment" + other_evaluation.save() + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["my_evaluation"]) + self.assertNotIn("Private expert comment", str(response.data)) + + def test_staff_can_open_expert_submission_detail(self): + self.authenticate(self.staff) + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + + +class EvaluationDraftCreateAPITests(ExpertEvaluationAPITestCase): + @property + def create_url(self): + return f"/submissions/{self.submission.pk}/evaluations/" + + def test_create_empty_draft(self): + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["status"], Evaluation.STATUS_DRAFT) + self.assertEqual(response.data["comment"], "") + self.assertEqual(response.data["scores"], []) + + def test_create_draft_with_scores(self): + self.authenticate() + + response = self.client.post( + self.create_url, + {"comment": "Strong", "scores": self.score_payload()}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["comment"], "Strong") + self.assertEqual(len(response.data["scores"]), 2) + + def test_repeated_post_returns_existing_without_changes(self): + self.authenticate() + first = self.client.post( + self.create_url, + {"comment": "Original", "scores": self.score_payload()}, + format="json", + ) + + second = self.client.post( + self.create_url, + {"comment": "Replacement", "scores": []}, + format="json", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["id"], first.data["id"]) + self.assertEqual(second.data["comment"], "Original") + self.assertEqual(len(second.data["scores"]), 2) + + def test_existing_submitted_evaluation_returns_409(self): + self.create_evaluation( + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_missing_assignment_returns_404(self): + submission = self.create_submission() + self.authenticate() + + response = self.client.post( + f"/submissions/{submission.pk}/evaluations/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + + def test_revoked_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + self.assignment.revoked_by = self.manager + self.assignment.revoked_at = timezone.now() + self.assignment.revoke_reason = "Reassigned" + self.assignment.save() + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_invalid_submission_status_returns_409(self): + for submission_status in ( + Submission.STATUS_DRAFT, + Submission.STATUS_RETURNED, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=submission_status): + submission = self.create_submission(status=submission_status) + self.create_assignment(submission=submission) + self.authenticate() + response = self.client.post( + f"/submissions/{submission.pk}/evaluations/", + {}, + format="json", + ) + self.assertEqual(response.status_code, 409) + + def test_criterion_from_another_program_returns_400(self): + criterion = create_rate_criteria(self.other_program, type="int") + self.authenticate() + + response = self.client.post( + self.create_url, + {"scores": [{"criterion_id": criterion.pk, "value": "5"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_non_numeric_criterion_returns_400(self): + criterion = create_rate_criteria(self.program, type="str") + self.authenticate() + + response = self.client.post( + self.create_url, + {"scores": [{"criterion_id": criterion.pk, "value": "5"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_fractional_int_and_out_of_range_values_return_400(self): + self.authenticate() + payloads = ( + {"scores": [{"criterion_id": self.int_criterion.pk, "value": "7.5"}]}, + {"scores": [{"criterion_id": self.int_criterion.pk, "value": "11"}]}, + {"scores": [{"criterion_id": self.float_criterion.pk, "value": "0.4"}]}, + ) + + for payload in payloads: + with self.subTest(payload=payload): + response = self.client.post( + self.create_url, + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(Evaluation.objects.exists()) + + def test_duplicate_criteria_return_400(self): + self.authenticate() + + response = self.client.post( + self.create_url, + { + "scores": [ + {"criterion_id": self.int_criterion.pk, "value": "7"}, + {"criterion_id": self.int_criterion.pk, "value": "8"}, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_my_evaluation_get_does_not_create(self): + self.authenticate() + + response = self.client.get(f"/submissions/{self.submission.pk}/evaluations/my/") + + self.assertEqual(response.status_code, 404) + self.assertFalse(Evaluation.objects.exists()) + + def test_my_evaluation_get_returns_own_scores(self): + evaluation = self.create_evaluation() + EvaluationScore.objects.create( + evaluation=evaluation, + criterion=self.int_criterion, + value=Decimal("8"), + ) + self.authenticate() + + response = self.client.get(f"/submissions/{self.submission.pk}/evaluations/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], evaluation.pk) + self.assertEqual(len(response.data["scores"]), 1) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_create="1/min")) + def test_create_is_scoped_throttled(self): + self.authenticate() + + first = self.client.post( + self.create_url, + {}, + format="json", + REMOTE_ADDR="203.0.113.90", + ) + second = self.client.post( + self.create_url, + {}, + format="json", + REMOTE_ADDR="203.0.113.90", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 429) + + +class EvaluationPatchAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Initial") + self.url = f"/evaluations/{self.evaluation.pk}/" + + def test_owner_updates_comment(self): + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Updated"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Updated") + + def test_scores_are_replaced_as_complete_set(self): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + self.authenticate() + + response = self.client.patch( + self.url, + {"scores": [{"criterion_id": self.float_criterion.pk, "value": "4.25"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(self.evaluation.scores.values_list("criterion_id", flat=True)), + {self.float_criterion.pk}, + ) + + def test_empty_scores_removes_all_scores(self): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + self.authenticate() + + response = self.client.patch(self.url, {"scores": []}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.evaluation.scores.exists()) + + def test_invalid_score_rolls_back_comment_and_scores(self): + original = EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + foreign = create_rate_criteria(self.other_program, type="int") + self.authenticate() + + response = self.client.patch( + self.url, + { + "comment": "Must rollback", + "scores": [ + {"criterion_id": self.float_criterion.pk, "value": "4"}, + {"criterion_id": foreign.pk, "value": "3"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Initial") + self.assertEqual( + list(self.evaluation.scores.values_list("id", flat=True)), + [original.pk], + ) + + def test_other_expert_and_manager_cannot_patch(self): + for user in (self.other_expert_user, self.manager): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + def test_revoked_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + self.assignment.revoked_by = self.manager + self.assignment.revoked_at = timezone.now() + self.assignment.revoke_reason = "Reassigned" + self.assignment.save() + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_submitted_evaluation_returns_409(self): + self.evaluation.status = Evaluation.STATUS_SUBMITTED + self.evaluation.submitted_at = timezone.now() + self.evaluation.save() + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_identical_patch_is_safe(self): + self.authenticate() + payload = {"comment": "Initial", "scores": self.score_payload()} + + first = self.client.patch(self.url, payload, format="json") + second = self.client.patch(self.url, payload, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(self.evaluation.scores.count(), 2) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_update="1/min")) + def test_patch_is_scoped_throttled(self): + self.authenticate() + + first = self.client.patch( + self.url, + {"comment": "First"}, + format="json", + REMOTE_ADDR="203.0.113.91", + ) + second = self.client.patch( + self.url, + {"comment": "Second"}, + format="json", + REMOTE_ADDR="203.0.113.91", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + +class EvaluationSubmitAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Ready") + for criterion, value in ( + (self.int_criterion, Decimal("8")), + (self.float_criterion, Decimal("4.5")), + ): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=criterion, + value=value, + ) + self.url = f"/evaluations/{self.evaluation.pk}/submit/" + + def test_submit_completes_evaluation_and_assignment(self): + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 200) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_SUBMITTED) + self.assertIsNotNone(self.evaluation.submitted_at) + self.assertIsNone(self.evaluation.total_score) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + self.assertIsNotNone(self.assignment.completed_at) + self.assertEqual( + self.assignment.completed_at, + self.evaluation.submitted_at, + ) + + def test_incomplete_criteria_return_400(self): + self.evaluation.scores.filter(criterion=self.float_criterion).delete() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 400) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_DRAFT) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_changed_range_is_revalidated_on_submit(self): + self.int_criterion.max_value = 5 + self.int_criterion.save() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 400) + + def test_wrong_submission_status_returns_409(self): + self.submission.status = Submission.STATUS_RETURNED + self.submission.submitted_at = None + self.submission.save() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_repeated_submit_is_idempotent_and_preserves_timestamp(self): + self.authenticate() + first = self.client.post(self.url, {}, format="json") + self.evaluation.refresh_from_db() + submitted_at = self.evaluation.submitted_at + + second = self.client.post(self.url, {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.submitted_at, submitted_at) + + def test_other_expert_cannot_submit(self): + self.authenticate(self.other_expert_user) + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 404) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_submit="1/min")) + def test_submit_is_scoped_throttled(self): + self.authenticate() + + first = self.client.post( + self.url, + {}, + format="json", + REMOTE_ADDR="203.0.113.92", + ) + second = self.client.post( + self.url, + {}, + format="json", + REMOTE_ADDR="203.0.113.92", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + +class EvaluationReadAndManagerAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Manager-visible") + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("7"), + ) + self.detail_url = f"/evaluations/{self.evaluation.pk}/" + self.manager_list_url = f"/programs/{self.program.pk}/evaluations/" + self.manager_detail_url = ( + f"/programs/{self.program.pk}/evaluations/{self.evaluation.pk}/" + ) + + def test_owner_manager_and_staff_can_read_evaluation(self): + for user in (self.expert_user, self.manager, self.staff): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, 200) + + def test_other_expert_and_participant_cannot_read_evaluation(self): + for user in (self.other_expert_user, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, 404) + + def test_manager_sees_own_program_evaluations_and_scores(self): + self.authenticate(self.manager) + + response = self.client.get(self.manager_list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + item = response.data["results"][0] + self.assertEqual(item["id"], self.evaluation.pk) + self.assertEqual(item["comment"], "Manager-visible") + self.assertEqual(len(item["scores"]), 1) + self.assertEqual(item["assignment"]["id"], self.assignment.pk) + + def test_other_manager_and_participant_cannot_use_manager_list(self): + for user in (self.other_manager, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.manager_list_url) + self.assertEqual(response.status_code, 403) + + def test_staff_can_use_manager_list(self): + self.authenticate(self.staff) + + response = self.client.get(self.manager_list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_filters_work(self): + self.authenticate(self.manager) + + response = self.client.get( + self.manager_list_url, + { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + "evaluation_status": Evaluation.STATUS_DRAFT, + "assignment_status": SubmissionExpertAssignment.STATUS_ASSIGNED, + "stage_key": "main", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_detail_is_scoped_to_program(self): + self.authenticate(self.manager) + own = self.client.get(self.manager_detail_url) + + self.authenticate(self.other_manager) + hidden = self.client.get(self.manager_detail_url) + + self.assertEqual(own.status_code, 200) + self.assertEqual(hidden.status_code, 403) + + def test_manager_api_is_read_only(self): + self.authenticate(self.manager) + + for method in ("post", "patch", "delete"): + with self.subTest(method=method): + response = getattr(self.client, method)( + self.manager_detail_url, + {"comment": "Forbidden", "scores": []}, + format="json", + ) + self.assertEqual(response.status_code, 405) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Manager-visible") + + def test_assignment_list_contains_evaluation_summary_without_pii(self): + self.authenticate(self.manager) + + response = self.client.get(f"/programs/{self.program.pk}/submission-assignments/") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["evaluation"]["id"], self.evaluation.pk) + self.assertEqual( + item["evaluation"]["status"], + Evaluation.STATUS_DRAFT, + ) + self.assertNotIn("form_data", str(item)) + + +class ConcurrentEvaluationSubmitTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + self.manager = create_user(prefix="concurrent-evaluation-manager") + self.participant = create_user(prefix="concurrent-evaluation-participant") + self.program = create_partner_program() + application = Application.objects.create( + program=self.program, + user=self.participant, + created_by=self.participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.submission = Submission.objects.create( + application=application, + program=self.program, + submitted_by=self.participant, + title="Concurrent solution", + status=Submission.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.expert_user = create_rate_expert( + prefix="concurrent-evaluation-expert", + program=self.program, + ) + self.assignment = SubmissionExpertAssignment.objects.create( + submission=self.submission, + expert=self.expert_user.expert, + assigned_by=self.manager, + ) + criterion = create_rate_criteria( + self.program, + type="int", + min_value=1, + max_value=10, + ) + self.evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert_user.expert, + ) + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=criterion, + value=Decimal("8"), + ) + + def test_concurrent_submit_keeps_consistent_terminal_state(self): + barrier = Barrier(2) + results = [] + errors = [] + + def worker(): + close_old_connections() + try: + user = type(self.expert_user).objects.get(pk=self.expert_user.pk) + barrier.wait() + evaluation = submit_evaluation( + evaluation_id=self.evaluation.pk, + user=user, + ) + results.append(evaluation.status) + except Exception as exc: # pragma: no cover - диагностируется результатом + errors.append(exc) + finally: + close_old_connections() + + threads = [Thread(target=worker), Thread(target=worker)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + self.assertFalse(errors) + self.assertEqual(results, [Evaluation.STATUS_SUBMITTED] * 2) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_SUBMITTED) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + self.assertEqual( + self.evaluation.submitted_at, + self.assignment.completed_at, + ) diff --git a/partner_programs/tests/test_submission_assignment_api.py b/partner_programs/tests/test_submission_assignment_api.py index cd54126b..4e5826ad 100644 --- a/partner_programs/tests/test_submission_assignment_api.py +++ b/partner_programs/tests/test_submission_assignment_api.py @@ -299,6 +299,7 @@ def test_response_is_minimal_and_does_not_expose_participant_data(self): "revoked_at", "revoke_reason", "evaluation_status", + "evaluation", }, ) self.assertEqual( @@ -312,6 +313,7 @@ def test_response_is_minimal_and_does_not_expose_participant_data(self): self.assertNotIn("form_data", item["submission"]) self.assertNotIn("email", item["expert"]) self.assertNotIn("participant", item) + self.assertIsNone(item["evaluation"]) def test_evaluation_status_reports_null_draft_and_submitted(self): no_evaluation = self.create_assignment() diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py index 680501a8..b6414e65 100644 --- a/partner_programs/throttling.py +++ b/partner_programs/throttling.py @@ -1,5 +1,17 @@ from rest_framework.throttling import ScopedRateThrottle +# Roadmap: DEV-051 +# Отдельный лимит частого autosave черновика Evaluation. + + +class PatchOnlyScopedRateThrottle(ScopedRateThrottle): + """Ограничивает только PATCH, не затрагивая чтение Evaluation.""" + + def allow_request(self, request, view): + if request.method != "PATCH": + return True + return super().allow_request(request, view) + class TeamMutationScopedRateThrottle(ScopedRateThrottle): """Ограничивает только mutation Team, не меняя глобальную throttle policy.""" diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 5b5cae6f..de3c3930 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -5,6 +5,10 @@ MyProgramApplicationView, ProgramApplicationCreateView, ) +from partner_programs.evaluation_views import ( + ProgramEvaluationDetailView, + ProgramEvaluationListView, +) from partner_programs.submission_assignment_views import ( ProgramSubmissionAssignmentListCreateView, ) @@ -29,6 +33,16 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), + path( + "/evaluations/", + ProgramEvaluationListView.as_view(), + name="evaluation-list", + ), + path( + "/evaluations//", + ProgramEvaluationDetailView.as_view(), + name="evaluation-detail", + ), path( "/submission-assignments/", ProgramSubmissionAssignmentListCreateView.as_view(), diff --git a/procollab/settings.py b/procollab/settings.py index 45369a6c..546bd2fc 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -199,6 +199,15 @@ default="60/min", cast=str, ), + "evaluation_create": config( + "DRF_THROTTLE_EVALUATION_CREATE", default="10/min", cast=str + ), + "evaluation_update": config( + "DRF_THROTTLE_EVALUATION_UPDATE", default="120/min", cast=str + ), + "evaluation_submit": config( + "DRF_THROTTLE_EVALUATION_SUBMIT", default="20/min", cast=str + ), }, } diff --git a/procollab/urls.py b/procollab/urls.py index 5a1d1443..b89c133b 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -66,6 +66,14 @@ "submissions/", include("partner_programs.submission_urls", namespace="submissions"), ), + path( + "evaluations/", + include("partner_programs.evaluation_urls", namespace="evaluations"), + ), + path( + "expert/", + include("partner_programs.expert_urls", namespace="expert_submissions"), + ), path( "submission-assignments//revoke/", SubmissionAssignmentRevokeView.as_view(), From a264a0010f3e654ec2559fe283cf4c608d40c44b Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 27 Jul 2026 16:20:17 +0300 Subject: [PATCH 2/2] Fix evaluation API CI compatibility --- partner_programs/tests/test_expert_evaluation_api.py | 8 +++++++- partner_programs/throttling.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/partner_programs/tests/test_expert_evaluation_api.py b/partner_programs/tests/test_expert_evaluation_api.py index 631818de..a21bec91 100644 --- a/partner_programs/tests/test_expert_evaluation_api.py +++ b/partner_programs/tests/test_expert_evaluation_api.py @@ -7,7 +7,12 @@ from django.conf import settings from django.core.cache import cache from django.db import close_old_connections -from django.test import TestCase, TransactionTestCase, override_settings +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) from django.utils import timezone from rest_framework.test import APIClient @@ -937,6 +942,7 @@ def setUp(self): value=Decimal("8"), ) + @skipUnlessDBFeature("has_select_for_update") def test_concurrent_submit_keeps_consistent_terminal_state(self): barrier = Barrier(2) results = [] diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py index b6414e65..6d20536b 100644 --- a/partner_programs/throttling.py +++ b/partner_programs/throttling.py @@ -1,4 +1,5 @@ from rest_framework.throttling import ScopedRateThrottle +from rest_framework.settings import api_settings # Roadmap: DEV-051 # Отдельный лимит частого autosave черновика Evaluation. @@ -7,6 +8,11 @@ class PatchOnlyScopedRateThrottle(ScopedRateThrottle): """Ограничивает только PATCH, не затрагивая чтение Evaluation.""" + def get_rate(self): + if not getattr(self, "scope", None): + return None + return api_settings.DEFAULT_THROTTLE_RATES.get(self.scope) + def allow_request(self, request, view): if request.method != "PATCH": return True