diff --git a/docs/submission-evaluation-domain.md b/docs/submission-evaluation-domain.md index cdbabba0..b2c8ab88 100644 --- a/docs/submission-evaluation-domain.md +++ b/docs/submission-evaluation-domain.md @@ -6,7 +6,11 @@ - `SubmissionExpertAssignment`, `Evaluation` и `EvaluationScore` реализованы в `partner_programs`; -- API и lifecycle service еще не реализованы; +- Assignment service и manager API реализованы: + `GET/POST /programs//submission-assignments/` и + `POST /submission-assignments//revoke/`; +- Expert Submission read API, Evaluation mutation API, frontend и `Result` + еще не реализованы; - временно используется существующий `project_rates.Criteria`; - дедлайном MVP остается существующий `datetime_evaluation_ends`; - `Result`, ranking и публикация итогов еще не реализованы. @@ -578,7 +582,7 @@ RFC не меняет: 1. **Evaluation models.** Добавить `SubmissionExpertAssignment`, `Evaluation`, `EvaluationScore`, migrations, admin, constraints и model tests. API не добавлять. -2. **Assignment service/API.** Транзакционные manager permissions, +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. diff --git a/partner_programs/serializers/submission_assignments.py b/partner_programs/serializers/submission_assignments.py new file mode 100644 index 00000000..db91b690 --- /dev/null +++ b/partner_programs/serializers/submission_assignments.py @@ -0,0 +1,97 @@ +from rest_framework import serializers + +from partner_programs.models import ( + Evaluation, + Submission, + SubmissionExpertAssignment, +) +from users.models import Expert + + +class SubmissionAssignmentSubmissionSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = ( + "id", + "title", + "status", + "stage_key", + "version", + "submitted_at", + ) + read_only_fields = fields + + +class SubmissionAssignmentExpertSerializer(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 SubmissionAssignmentReadSerializer(serializers.ModelSerializer): + submission = SubmissionAssignmentSubmissionSerializer(read_only=True) + expert = SubmissionAssignmentExpertSerializer(read_only=True) + assigned_by_id = serializers.IntegerField(read_only=True) + revoked_by_id = serializers.IntegerField(read_only=True) + evaluation_status = serializers.SerializerMethodField() + + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "status", + "submission", + "expert", + "assigned_by_id", + "assigned_at", + "completed_at", + "revoked_by_id", + "revoked_at", + "revoke_reason", + "evaluation_status", + ) + read_only_fields = fields + + def get_evaluation_status(self, assignment): + if hasattr(assignment, "annotated_evaluation_status"): + return assignment.annotated_evaluation_status + return ( + Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + ) + .values_list("status", flat=True) + .first() + ) + + +class SubmissionAssignmentCreateSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, write_only=True) + expert_id = serializers.IntegerField(min_value=1, write_only=True) + + +class SubmissionAssignmentRevokeSerializer(serializers.Serializer): + reason = serializers.CharField( + allow_blank=False, + trim_whitespace=True, + write_only=True, + ) + + +class SubmissionAssignmentFilterSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, required=False) + expert_id = serializers.IntegerField(min_value=1, required=False) + status = serializers.ChoiceField( + choices=SubmissionExpertAssignment.STATUS_CHOICES, + required=False, + ) diff --git a/partner_programs/services/submission_assignments.py b/partner_programs/services/submission_assignments.py new file mode 100644 index 00000000..40ae2022 --- /dev/null +++ b/partner_programs/services/submission_assignments.py @@ -0,0 +1,207 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.utils import timezone + +from partner_programs.models import ( + Evaluation, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from users.models import Expert + + +class SubmissionAssignmentServiceError(Exception): + code = "submission_assignment_error" + default_detail = "Unable to change the submission assignment." + 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 SubmissionAssignmentValidationError(SubmissionAssignmentServiceError): + code = "invalid_assignment" + + +class SubmissionAssignmentConflictError(SubmissionAssignmentServiceError): + code = "assignment_conflict" + + +class SubmissionStatusConflictError(SubmissionAssignmentConflictError): + code = "submission_status_conflict" + default_detail = "Only submitted or final submissions can be assigned." + + +class AssignmentCompletedError(SubmissionAssignmentConflictError): + code = "assignment_completed" + default_detail = "A completed assignment cannot be changed." + + +class SubmittedEvaluationExistsError(SubmissionAssignmentConflictError): + code = "submitted_evaluation_exists" + default_detail = "An assignment with a submitted evaluation cannot be revoked." + + +@dataclass(frozen=True) +class SubmissionAssignmentCreationResult: + assignment: SubmissionExpertAssignment + created: bool + + +def _get_active_assignment(*, submission_id, expert_id): + return ( + SubmissionExpertAssignment.objects.filter( + submission_id=submission_id, + expert_id=expert_id, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ) + .order_by("-created_at", "-id") + .first() + ) + + +def _result_for_existing_active(assignment): + if assignment.status == SubmissionExpertAssignment.STATUS_COMPLETED: + raise AssignmentCompletedError() + return SubmissionAssignmentCreationResult( + assignment=assignment, + created=False, + ) + + +def _get_submission(*, program, submission_id): + submission = ( + Submission.objects.select_for_update() + .select_related("program") + .filter(pk=submission_id) + .first() + ) + if submission is None: + raise SubmissionAssignmentValidationError( + "Submission not found.", + field="submission_id", + ) + if submission.program_id != program.pk: + raise SubmissionAssignmentValidationError( + "Submission does not belong to this program.", + field="submission_id", + ) + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionStatusConflictError() + return submission + + +def _get_expert(*, program, expert_id): + expert = Expert.objects.select_related("user").filter(pk=expert_id).first() + if expert is None: + raise SubmissionAssignmentValidationError( + "Expert not found.", + field="expert_id", + ) + if not expert.programs.filter(pk=program.pk).exists(): + raise SubmissionAssignmentValidationError( + "Expert does not belong to this program.", + field="expert_id", + ) + return expert + + +def create_submission_assignment( + *, + program: PartnerProgram, + submission_id: int, + expert_id: int, + actor, +) -> SubmissionAssignmentCreationResult: + with transaction.atomic(): + submission = _get_submission( + program=program, + submission_id=submission_id, + ) + expert = _get_expert(program=program, expert_id=expert_id) + + existing = _get_active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if existing is not None: + return _result_for_existing_active(existing) + + try: + with transaction.atomic(): + assignment = SubmissionExpertAssignment.objects.create( + submission=submission, + expert=expert, + assigned_by=actor, + ) + except (IntegrityError, DjangoValidationError) as exc: + existing = _get_active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if existing is not None: + return _result_for_existing_active(existing) + raise SubmissionAssignmentConflictError( + "The assignment changed concurrently. Please retry." + ) from exc + + return SubmissionAssignmentCreationResult( + assignment=assignment, + created=True, + ) + + +def revoke_submission_assignment( + *, + assignment: SubmissionExpertAssignment, + actor, + reason: str, +) -> SubmissionExpertAssignment: + reason = reason.strip() + if not reason: + raise SubmissionAssignmentValidationError( + "A non-empty reason is required.", + field="reason", + ) + + with transaction.atomic(): + assignment = ( + SubmissionExpertAssignment.objects.select_for_update() + .select_related("submission", "expert") + .get(pk=assignment.pk) + ) + if assignment.status == SubmissionExpertAssignment.STATUS_REVOKED: + return assignment + if assignment.status == SubmissionExpertAssignment.STATUS_COMPLETED: + raise AssignmentCompletedError() + if Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + status=Evaluation.STATUS_SUBMITTED, + ).exists(): + raise SubmittedEvaluationExistsError() + + assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + assignment.revoked_by = actor + assignment.revoked_at = timezone.now() + assignment.revoke_reason = reason + assignment.completed_at = None + assignment.save( + update_fields=[ + "status", + "revoked_by", + "revoked_at", + "revoke_reason", + "completed_at", + "updated_at", + ] + ) + return assignment diff --git a/partner_programs/submission_assignment_views.py b/partner_programs/submission_assignment_views.py new file mode 100644 index 00000000..e9d2c9b5 --- /dev/null +++ b/partner_programs/submission_assignment_views.py @@ -0,0 +1,148 @@ +from django.db.models import OuterRef, Subquery +from django.shortcuts import get_object_or_404 +from rest_framework import status +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.models import ( + Evaluation, + PartnerProgram, + SubmissionExpertAssignment, +) +from partner_programs.pagination import PartnerProgramPagination +from partner_programs.permissions import IsAdminOrManagerOfProgram +from partner_programs.serializers.submission_assignments import ( + SubmissionAssignmentCreateSerializer, + SubmissionAssignmentFilterSerializer, + SubmissionAssignmentReadSerializer, + SubmissionAssignmentRevokeSerializer, +) +from partner_programs.services.submission_assignments import ( + SubmissionAssignmentConflictError, + SubmissionAssignmentServiceError, + SubmissionAssignmentValidationError, + create_submission_assignment, + revoke_submission_assignment, +) + + +def _assignment_queryset(): + evaluation_status = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ).values("status")[:1] + return SubmissionExpertAssignment.objects.select_related( + "submission", + "expert", + "expert__user", + "assigned_by", + "revoked_by", + ).annotate( + annotated_evaluation_status=Subquery(evaluation_status), + ) + + +def _domain_error_response(exc): + if isinstance(exc, SubmissionAssignmentValidationError): + return Response( + {exc.field: [exc.detail]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, SubmissionAssignmentConflictError): + return Response( + { + "detail": exc.detail, + "code": exc.code, + }, + status=status.HTTP_409_CONFLICT, + ) + raise exc + + +class ProgramPermissionMixin: + def check_permissions(self, request): + program_id = self.kwargs.get("program_id") + if program_id is None and getattr(self, "swagger_fake_view", False): + return super().check_permissions(request) + self.program = get_object_or_404( + PartnerProgram, + pk=program_id, + ) + return super().check_permissions(request) + + +class ProgramSubmissionAssignmentListCreateView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "submission_assignment_create" + pagination_class = PartnerProgramPagination + + def get(self, request, program_id): + filter_serializer = SubmissionAssignmentFilterSerializer( + data=request.query_params + ) + filter_serializer.is_valid(raise_exception=True) + + queryset = _assignment_queryset().filter( + submission__program_id=self.program.pk, + ) + filters = filter_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 "status" in filters: + queryset = queryset.filter(status=filters["status"]) + queryset = queryset.order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = SubmissionAssignmentReadSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request, program_id): + serializer = SubmissionAssignmentCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = create_submission_assignment( + program=self.program, + submission_id=serializer.validated_data["submission_id"], + expert_id=serializer.validated_data["expert_id"], + actor=request.user, + ) + except SubmissionAssignmentServiceError as exc: + return _domain_error_response(exc) + + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + SubmissionAssignmentReadSerializer(result.assignment).data, + status=response_status, + ) + + +class SubmissionAssignmentRevokeView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, user, assignment_id): + queryset = _assignment_queryset() + if not (user.is_staff or user.is_superuser): + queryset = queryset.filter(submission__program__managers=user) + return get_object_or_404(queryset, pk=assignment_id) + + def post(self, request, assignment_id): + assignment = self.get_object(request.user, assignment_id) + serializer = SubmissionAssignmentRevokeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + assignment = revoke_submission_assignment( + assignment=assignment, + actor=request.user, + reason=serializer.validated_data["reason"], + ) + except SubmissionAssignmentServiceError as exc: + return _domain_error_response(exc) + return Response(SubmissionAssignmentReadSerializer(assignment).data) diff --git a/partner_programs/tests/test_submission_assignment_api.py b/partner_programs/tests/test_submission_assignment_api.py new file mode 100644 index 00000000..cd54126b --- /dev/null +++ b/partner_programs/tests/test_submission_assignment_api.py @@ -0,0 +1,950 @@ +from decimal import Decimal +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.db import transaction +from django.test import SimpleTestCase, TestCase, override_settings +from django.urls import Resolver404, resolve, reverse +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.submission_assignments import ( + AssignmentCompletedError, + create_submission_assignment, +) +from partner_programs.services import ( + submission_assignments as submission_assignment_service, +) +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 SubmissionAssignmentTestCase(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.manager = create_user(prefix="assignment-manager") + self.other_manager = create_user(prefix="assignment-other-manager") + self.outsider = create_user(prefix="assignment-outsider") + self.staff = create_user(prefix="assignment-staff", is_staff=True) + self.participant = create_user(prefix="assignment-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="assignment-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + self.other_expert_user = create_rate_expert( + prefix="assignment-other-expert", + program=self.other_program, + ) + self.other_expert = self.other_expert_user.expert + self.submission = self.create_submission(participant=self.participant) + self.list_url = f"/programs/{self.program.pk}/submission-assignments/" + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.manager) + + def create_submission( + self, + *, + program=None, + participant=None, + status=Submission.STATUS_SUBMITTED, + title="Assignment solution", + ): + program = program or self.program + participant = participant or create_user( + prefix="assignment-submission-participant" + ) + application = Application.objects.create( + program=program, + user=participant, + created_by=participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + return Submission.objects.create( + application=application, + program=program, + submitted_by=participant, + title=title, + status=status, + submitted_at=( + timezone.now() + if status in (Submission.STATUS_SUBMITTED, Submission.STATUS_FINAL) + else None + ), + ) + + def create_assignment(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def create_payload(self, **overrides): + payload = { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + } + payload.update(overrides) + return payload + + +class SubmissionAssignmentListAPITests(SubmissionAssignmentTestCase): + def test_unauthenticated_user_gets_401(self): + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 401) + + def test_program_manager_can_list_assignments(self): + self.create_assignment() + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_outsider_gets_403(self): + self.authenticate(self.outsider) + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 403) + + def test_staff_can_list_assignments(self): + self.create_assignment() + self.authenticate(self.staff) + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_get_unknown_program_gets_404(self): + self.authenticate() + + response = self.client.get( + f"/programs/{self.program.pk + self.other_program.pk + 1000}/" + "submission-assignments/" + ) + + self.assertEqual(response.status_code, 404) + + def test_post_unknown_program_gets_404(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.pk + self.other_program.pk + 1000}/" + "submission-assignments/", + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 404) + + def test_list_contains_only_requested_program(self): + own_assignment = self.create_assignment() + other_submission = self.create_submission( + program=self.other_program, + participant=create_user(prefix="other-program-participant"), + ) + SubmissionExpertAssignment.objects.create( + submission=other_submission, + expert=self.other_expert, + assigned_by=self.other_manager, + ) + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [own_assignment.pk], + ) + + def test_list_includes_revoked_history_newest_first(self): + older = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="First episode", + ) + newer = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="Second episode", + ) + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [newer.pk, older.pk], + ) + + def test_list_uses_project_limit_offset_pagination(self): + for index in range(11): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason=f"History {index}", + ) + self.authenticate() + + first_page = self.client.get(self.list_url) + second_page = self.client.get(self.list_url, {"offset": 10}) + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(first_page.data["count"], 11) + self.assertEqual(len(first_page.data["results"]), 10) + self.assertEqual(second_page.status_code, 200) + self.assertEqual(len(second_page.data["results"]), 1) + + def test_submission_expert_and_status_filters_work(self): + matching = self.create_assignment() + second_expert_user = create_rate_expert( + prefix="assignment-filter-expert", + program=self.program, + ) + second_submission = self.create_submission(title="Second solution") + SubmissionExpertAssignment.objects.create( + submission=second_submission, + expert=second_expert_user.expert, + assigned_by=self.manager, + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="History", + ) + self.authenticate() + + response = self.client.get( + self.list_url, + { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + "status": SubmissionExpertAssignment.STATUS_ASSIGNED, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [matching.pk], + ) + + def test_invalid_filters_get_400(self): + self.authenticate() + + for query in ( + {"submission_id": "not-a-number"}, + {"expert_id": "not-a-number"}, + {"status": "unknown"}, + ): + with self.subTest(query=query): + response = self.client.get(self.list_url, query) + self.assertEqual(response.status_code, 400) + + def test_response_is_minimal_and_does_not_expose_participant_data(self): + self.submission.form_data = {"private": "secret"} + self.submission.save() + self.create_assignment() + self.authenticate() + + response = self.client.get(self.list_url) + + item = response.data["results"][0] + self.assertEqual( + set(item), + { + "id", + "status", + "submission", + "expert", + "assigned_by_id", + "assigned_at", + "completed_at", + "revoked_by_id", + "revoked_at", + "revoke_reason", + "evaluation_status", + }, + ) + self.assertEqual( + set(item["submission"]), + {"id", "title", "status", "stage_key", "version", "submitted_at"}, + ) + self.assertEqual( + set(item["expert"]), + {"id", "user_id", "first_name", "last_name"}, + ) + self.assertNotIn("form_data", item["submission"]) + self.assertNotIn("email", item["expert"]) + self.assertNotIn("participant", item) + + def test_evaluation_status_reports_null_draft_and_submitted(self): + no_evaluation = self.create_assignment() + draft_submission = self.create_submission(title="Draft evaluation") + draft_expert_user = create_rate_expert( + prefix="assignment-draft-evaluation-expert", + program=self.program, + ) + draft_assignment = SubmissionExpertAssignment.objects.create( + submission=draft_submission, + expert=draft_expert_user.expert, + assigned_by=self.manager, + ) + Evaluation.objects.create( + submission=draft_submission, + expert=draft_expert_user.expert, + ) + submitted_submission = self.create_submission(title="Submitted evaluation") + submitted_expert_user = create_rate_expert( + prefix="assignment-submitted-evaluation-expert", + program=self.program, + ) + submitted_assignment = SubmissionExpertAssignment.objects.create( + submission=submitted_submission, + expert=submitted_expert_user.expert, + assigned_by=self.manager, + ) + Evaluation.objects.create( + submission=submitted_submission, + expert=submitted_expert_user.expert, + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.get(self.list_url, {"limit": 20}) + + statuses = { + item["id"]: item["evaluation_status"] for item in response.data["results"] + } + self.assertIsNone(statuses[no_evaluation.pk]) + self.assertEqual(statuses[draft_assignment.pk], Evaluation.STATUS_DRAFT) + self.assertEqual( + statuses[submitted_assignment.pk], + Evaluation.STATUS_SUBMITTED, + ) + + +class SubmissionAssignmentCreateAPITests(SubmissionAssignmentTestCase): + def test_manager_creates_assigned_assignment_with_201(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["status"], "assigned") + self.assertEqual(SubmissionExpertAssignment.objects.count(), 1) + + def test_assigned_by_is_request_user(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["assigned_by_id"], self.manager.pk) + self.assertEqual( + SubmissionExpertAssignment.objects.get().assigned_by, + self.manager, + ) + + def test_repeated_post_returns_same_assignment_with_200(self): + self.authenticate() + + first = self.client.post(self.list_url, self.create_payload(), format="json") + second = self.client.post(self.list_url, self.create_payload(), format="json") + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["id"], first.data["id"]) + self.assertEqual(SubmissionExpertAssignment.objects.count(), 1) + + def test_repeated_post_preserves_assigned_timestamp_and_actor(self): + original = self.create_assignment() + original_assigned_at = original.assigned_at + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 200) + original.refresh_from_db() + self.assertEqual(original.assigned_at, original_assigned_at) + self.assertEqual(original.assigned_by, self.manager) + + def test_new_episode_is_created_after_revocation(self): + revoked = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="Previous episode", + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertNotEqual(response.data["id"], revoked.pk) + self.assertEqual(SubmissionExpertAssignment.objects.count(), 2) + + def test_completed_assignment_returns_409(self): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "assignment_completed") + + def test_submission_from_another_program_gets_field_error(self): + other_submission = self.create_submission( + program=self.other_program, + participant=create_user(prefix="wrong-program-participant"), + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=other_submission.pk), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("submission_id", response.data) + + def test_expert_from_another_program_gets_field_error(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(expert_id=self.other_expert.pk), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("expert_id", response.data) + + def test_submitted_submission_is_assignable(self): + self.assertEqual(self.submission.status, Submission.STATUS_SUBMITTED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + + def test_final_submission_is_assignable(self): + final_submission = self.create_submission(status=Submission.STATUS_FINAL) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=final_submission.pk), + format="json", + ) + + self.assertEqual(response.status_code, 201) + + def test_draft_submission_returns_409(self): + draft = self.create_submission(status=Submission.STATUS_DRAFT) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=draft.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_returned_submission_returns_409(self): + returned = self.create_submission(status=Submission.STATUS_RETURNED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=returned.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_cancelled_submission_returns_409(self): + cancelled = self.create_submission(status=Submission.STATUS_CANCELLED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=cancelled.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_unknown_ids_get_field_errors(self): + self.authenticate() + + for payload, field in ( + (self.create_payload(submission_id=999999), "submission_id"), + (self.create_payload(expert_id=999999), "expert_id"), + ): + with self.subTest(field=field): + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.data) + + def test_outsider_cannot_create_assignment(self): + self.authenticate(self.outsider) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(SubmissionExpertAssignment.objects.exists()) + + def test_staff_can_create_assignment(self): + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["assigned_by_id"], self.staff.pk) + + def test_invalid_payload_gets_400(self): + self.authenticate() + + for payload in ( + {}, + {"submission_id": "bad", "expert_id": self.expert.pk}, + {"submission_id": self.submission.pk, "expert_id": "bad"}, + ): + with self.subTest(payload=payload): + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, 400) + + @override_settings( + REST_FRAMEWORK=throttle_settings(submission_assignment_create="1/min") + ) + def test_create_is_scoped_throttled(self): + self.authenticate() + second_expert = create_rate_expert( + prefix="assignment-throttle-expert", + program=self.program, + ).expert + + first = self.client.post( + self.list_url, + self.create_payload(), + format="json", + REMOTE_ADDR="203.0.113.80", + ) + second = self.client.post( + self.list_url, + self.create_payload(expert_id=second_expert.pk), + format="json", + REMOTE_ADDR="203.0.113.80", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 429) + + +class SubmissionAssignmentRevokeAPITests(SubmissionAssignmentTestCase): + def setUp(self): + super().setUp() + self.assignment = self.create_assignment() + self.revoke_url = f"/submission-assignments/{self.assignment.pk}/revoke/" + + def test_manager_revokes_assigned_assignment(self): + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Workload redistribution"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_REVOKED, + ) + + def test_revoke_records_actor_timestamp_and_trimmed_reason(self): + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": " Workload redistribution "}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.revoked_by, self.manager) + self.assertIsNotNone(self.assignment.revoked_at) + self.assertEqual( + self.assignment.revoke_reason, + "Workload redistribution", + ) + self.assertIsNone(self.assignment.completed_at) + + def test_repeated_revoke_is_idempotent(self): + self.authenticate() + + first = self.client.post( + self.revoke_url, + {"reason": "Initial reason"}, + format="json", + ) + second = self.client.post( + self.revoke_url, + {"reason": "Replacement reason"}, + format="json", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["id"], second.data["id"]) + + def test_repeated_revoke_preserves_original_audit_fields(self): + self.authenticate() + self.client.post( + self.revoke_url, + {"reason": "Initial reason"}, + format="json", + ) + self.assignment.refresh_from_db() + original = ( + self.assignment.revoked_at, + self.assignment.revoked_by_id, + self.assignment.revoke_reason, + ) + + self.authenticate(self.staff) + response = self.client.post( + self.revoke_url, + {"reason": "Replacement reason"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual( + ( + self.assignment.revoked_at, + self.assignment.revoked_by_id, + self.assignment.revoke_reason, + ), + original, + ) + + def test_completed_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + self.assignment.completed_at = timezone.now() + self.assignment.save() + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Cannot revoke"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_submitted_evaluation_blocks_revoke(self): + Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Cannot revoke"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_draft_evaluation_does_not_block_revoke(self): + Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Reassign"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + + def test_draft_evaluation_and_scores_are_preserved(self): + evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + criterion = create_rate_criteria( + self.program, + type="float", + ) + score = EvaluationScore.objects.create( + evaluation=evaluation, + criterion=criterion, + value=Decimal("7.5"), + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Reassign"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Evaluation.objects.filter(pk=evaluation.pk).exists()) + self.assertTrue(EvaluationScore.objects.filter(pk=score.pk).exists()) + score.refresh_from_db() + self.assertEqual(score.value, Decimal("7.500000")) + + def test_other_program_manager_gets_404(self): + self.authenticate(self.other_manager) + + response = self.client.post( + self.revoke_url, + {"reason": "Hidden assignment"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_staff_can_revoke_assignment(self): + self.authenticate(self.staff) + + response = self.client.post( + self.revoke_url, + {"reason": "Staff reassignment"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.revoked_by, self.staff) + + def test_delete_is_not_supported(self): + self.authenticate() + + response = self.client.delete(self.revoke_url) + + self.assertEqual(response.status_code, 405) + self.assertTrue( + SubmissionExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + + def test_reason_is_required_and_nonempty(self): + self.authenticate() + + for payload in ({}, {"reason": ""}, {"reason": " "}): + with self.subTest(payload=payload): + response = self.client.post( + self.revoke_url, + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + +class SubmissionAssignmentServiceRaceTests(SubmissionAssignmentTestCase): + def test_integrity_error_race_returns_existing_assigned_episode(self): + existing = self.create_assignment() + real_get_active_assignment = submission_assignment_service._get_active_assignment + lookup_count = 0 + + def simulate_race_lookup(**kwargs): + nonlocal lookup_count + lookup_count += 1 + if lookup_count == 1: + return None + return real_get_active_assignment(**kwargs) + + with patch( + "partner_programs.services.submission_assignments." "_get_active_assignment", + side_effect=simulate_race_lookup, + ), patch.object( + SubmissionExpertAssignment, + "full_clean", + return_value=None, + ): + result = create_submission_assignment( + program=self.program, + submission_id=self.submission.pk, + expert_id=self.expert.pk, + actor=self.manager, + ) + + self.assertFalse(result.created) + self.assertEqual(result.assignment, existing) + self.assertEqual(lookup_count, 2) + self.assertEqual( + SubmissionExpertAssignment.objects.get(pk=existing.pk), + existing, + ) + self.assertFalse(transaction.get_connection().needs_rollback) + + def test_integrity_error_race_with_completed_episode_is_conflict(self): + existing = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + real_get_active_assignment = submission_assignment_service._get_active_assignment + lookup_count = 0 + + def simulate_race_lookup(**kwargs): + nonlocal lookup_count + lookup_count += 1 + if lookup_count == 1: + return None + return real_get_active_assignment(**kwargs) + + with patch( + "partner_programs.services.submission_assignments." "_get_active_assignment", + side_effect=simulate_race_lookup, + ), patch.object( + SubmissionExpertAssignment, + "full_clean", + return_value=None, + ): + with self.assertRaises(AssignmentCompletedError): + create_submission_assignment( + program=self.program, + submission_id=self.submission.pk, + expert_id=self.expert.pk, + actor=self.manager, + ) + + self.assertEqual(lookup_count, 2) + self.assertEqual( + SubmissionExpertAssignment.objects.get(pk=existing.pk), + existing, + ) + self.assertFalse(transaction.get_connection().needs_rollback) + + +class SubmissionAssignmentURLContractTests(SimpleTestCase): + def test_reverse_builds_exact_assignment_urls(self): + self.assertEqual( + reverse( + "partner_programs:submission-assignment-list-create", + kwargs={"program_id": 17}, + ), + "/programs/17/submission-assignments/", + ) + self.assertEqual( + reverse( + "submission-assignment-revoke", + kwargs={"assignment_id": 23}, + ), + "/submission-assignments/23/revoke/", + ) + + def test_incorrect_prefixed_assignment_urls_do_not_resolve(self): + for url in ( + "/programs/programs/17/submission-assignments/", + "/programs/submission-assignments/23/revoke/", + ): + with self.subTest(url=url): + with self.assertRaises(Resolver404): + resolve(url) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 4238f812..5b5cae6f 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -5,6 +5,9 @@ MyProgramApplicationView, ProgramApplicationCreateView, ) +from partner_programs.submission_assignment_views import ( + ProgramSubmissionAssignmentListCreateView, +) from partner_programs.views import ( PartnerProgramCreateUserAndRegister, PartnerProgramDataSchema, @@ -26,6 +29,11 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), + path( + "/submission-assignments/", + ProgramSubmissionAssignmentListCreateView.as_view(), + name="submission-assignment-list-create", + ), path( "/applications/my/", MyProgramApplicationView.as_view(), diff --git a/procollab/settings.py b/procollab/settings.py index 32498e15..45369a6c 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -194,6 +194,11 @@ "submission_create": config( "DRF_THROTTLE_SUBMISSION_CREATE", default="10/min", cast=str ), + "submission_assignment_create": config( + "DRF_THROTTLE_SUBMISSION_ASSIGNMENT_CREATE", + default="60/min", + cast=str, + ), }, } diff --git a/procollab/urls.py b/procollab/urls.py index 9a807a41..5a1d1443 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -9,6 +9,9 @@ TokenRefreshView, TokenVerifyView, ) +from partner_programs.submission_assignment_views import ( + SubmissionAssignmentRevokeView, +) from users.authentication import ActivityTrackingJWTAuthentication from users.token_views import ThrottledTokenObtainPairView @@ -63,6 +66,11 @@ "submissions/", include("partner_programs.submission_urls", namespace="submissions"), ), + path( + "submission-assignments//revoke/", + SubmissionAssignmentRevokeView.as_view(), + name="submission-assignment-revoke", + ), path( "team-invites/", include("partner_programs.team_invite_urls", namespace="team_invites"),