From 326348e405ce608351c6af599b40bdbb10b88051 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Thu, 22 Oct 2026 00:31:30 +0300 Subject: [PATCH] feat: add confirmed organizer actions for program projects --- partner_programs/management_views.py | 22 + partner_programs/serializers/management.py | 31 + .../services/management_project_actions.py | 342 +++++++++++ .../tests/test_management_project_actions.py | 547 ++++++++++++++++++ partner_programs/urls.py | 10 + 5 files changed, 952 insertions(+) create mode 100644 partner_programs/services/management_project_actions.py create mode 100644 partner_programs/tests/test_management_project_actions.py diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 2846cd54..1eef5ffb 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -271,6 +271,28 @@ def get(self, request, pk, link_id): return Response(team_data(managed_project(program, link_id))) +class ManagementProjectActionPreviewView(ManagementBase): + def post(self, request, pk): + from partner_programs.serializers.management import ManagementProjectActionWrite + from partner_programs.services.management_project_actions import preview_action + + managed_program(pk, request.user) + body = ManagementProjectActionWrite(data=request.data) + body.is_valid(raise_exception=True) + return Response(preview_action(pk, request.user, body.validated_data)) + + +class ManagementProjectActionApplyView(ManagementBase): + def post(self, request, pk): + from partner_programs.serializers.management import ManagementProjectActionApply + from partner_programs.services.management_project_actions import apply_action + + managed_program(pk, request.user) + body = ManagementProjectActionApply(data=request.data) + body.is_valid(raise_exception=True) + return Response(apply_action(pk, request.user, body.validated_data)) + + class ManagementEvaluationView(ManagementBase): def get(self, request, pk, link_id): program = managed_program(pk, request.user) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index cbb4051f..8a3f2225 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -154,6 +154,37 @@ def validate(self, attrs): return attrs +class ManagementProjectActionWrite(ManagementIdsWrite): + action = serializers.ChoiceField(choices=("change_case", "detach", "submit")) + case_name = serializers.CharField( + required=False, max_length=1000, trim_whitespace=False + ) + + def to_internal_value(self, data): + from collections.abc import Mapping + + if not isinstance(data, Mapping): + raise serializers.ValidationError( + {"detail": "Ожидается объект с параметрами действия."} + ) + return super().to_internal_value(data) + + def validate(self, attrs): + if attrs["action"] != "submit" and len(attrs["ids"]) != 1: + raise serializers.ValidationError({"ids": "Выберите один проект."}) + if attrs["action"] == "change_case" and not attrs.get("case_name"): + raise serializers.ValidationError({"case_name": "Выберите кейс."}) + if attrs["action"] != "change_case" and "case_name" in attrs: + raise serializers.ValidationError( + {"case_name": "Кейс не используется в этом действии."} + ) + return attrs + + +class ManagementProjectActionApply(ManagementProjectActionWrite): + preview_token = serializers.CharField(max_length=200000) + + BASIC_FIELDS = ( "name", "city", diff --git a/partner_programs/services/management_project_actions.py b/partner_programs/services/management_project_actions.py new file mode 100644 index 00000000..e11a23a2 --- /dev/null +++ b/partner_programs/services/management_project_actions.py @@ -0,0 +1,342 @@ +"""Подтверждаемые действия организатора над конкретными связями программы.""" + +import hashlib +import json +import logging + +from django.core import signing +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.db import transaction +from django.http import Http404 +from django.utils import timezone +from rest_framework.exceptions import APIException, ValidationError + +from invites.models import Invite +from partner_programs.models import ( + PartnerProgram, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.constants import PROGRAM_CASE_FIELD_NAME +from partner_programs.serializers.fields import PartnerProgramFieldValueUpdateSerializer +from partner_programs.services.case_fields import ( + get_program_case_field, + validate_case_value, +) +from partner_programs.services.management import managed_program +from project_rates.models import ProjectExpertAssignment, ProjectScore +from project_rates.workspace import eligible +from projects import team_policy +from projects.models import Project +from projects.team_errors import TeamError + +logger = logging.getLogger(__name__) +SALT = "management-project-actions-v1" + + +class ActionConflict(APIException): + status_code = 409 + default_detail = ( + "Данные проектов изменились. Проверьте их и подтвердите действие заново." + ) + default_code = "project_action_conflict" + + +def load_links(program, ids): + links = list( + PartnerProgramProject.objects.filter(partner_program=program, pk__in=ids) + .select_related("project") + .prefetch_related("field_values") + .order_by("pk") + ) + if len(links) != len(ids): + raise Http404 + return links + + +def incompatible_assignments(program, link, case_name): + scopes = {scope.expert_id: scope for scope in program.expert_case_scopes.all()} + protected = set( + ProjectScore.objects.filter( + project_id=link.project_id, criteria__partner_program=program + ).values_list("user_id", flat=True) + ) + return [ + assignment.pk + for assignment in ProjectExpertAssignment.objects.filter( + partner_program=program, project_id=link.project_id + ).select_related("expert") + if assignment.expert.user_id not in protected + and not eligible(scopes.get(assignment.expert_id), case_name) + ] + + +def submission_errors(program, link, fields): + if link.submitted: + return [] + errors = [] + if not program.is_competitive: + errors.append("В программе нет сдачи конкурсных решений.") + if link.project.draft: + errors.append("Заявка в черновике. Сначала завершите ее оформление.") + values = {value.field_id: value.value_text for value in link.field_values.all()} + validator = PartnerProgramFieldValueUpdateSerializer() + for field in fields: + value = values.get(field.pk) + if value is None or not str(value).strip(): + if field.is_required or field.name == PROGRAM_CASE_FIELD_NAME: + errors.append(f"Заполните поле «{field.label}».") + continue + try: + if field.name == PROGRAM_CASE_FIELD_NAME: + validate_case_value(field, value) + validator.validate({"field": field, "value_text": value}) + except DjangoValidationError as error: + errors.append(f"Поле «{field.label}»: {error.messages[0]}") + except ValidationError as error: + detail = error.detail + message = str(detail[0]) if isinstance(detail, list) else str(detail) + errors.append(f"Поле «{field.label}»: {message}") + try: + team_policy.validate_submission_team(link.project, link) + except TeamError as error: + errors.append(str(error.detail["detail"])) + return errors + + +def snapshot(program, links, fields): + ids = [link.pk for link in links] + project_ids = [link.project_id for link in links] + state = { + "program": [program.is_competitive, program.datetime_updated], + "links": [ + [ + link.pk, + link.project_id, + link.submitted, + link.datetime_updated, + link.project.datetime_updated, + link.project.draft, + link.project.leader_id, + ] + for link in links + ], + "fields": [ + [ + field.pk, + field.name, + field.label, + field.field_type, + field.is_required, + field.options, + ] + for field in fields + ], + "values": list( + PartnerProgramFieldValue.objects.filter(program_project_id__in=ids) + .order_by("pk") + .values_list("pk", "program_project_id", "field_id", "value_text") + ), + "scores": list( + ProjectScore.objects.filter( + project_id__in=project_ids, criteria__partner_program=program + ) + .order_by("pk") + .values_list("pk", "value") + ), + "assignments": list( + ProjectExpertAssignment.objects.filter( + partner_program=program, project_id__in=project_ids + ) + .order_by("pk") + .values_list("pk", "expert_id") + ), + "scopes": list( + program.expert_case_scopes.order_by("pk").values_list( + "expert_id", "all_cases", "case_names" + ) + ), + "teams": list( + Project.objects.filter(pk__in=project_ids) + .order_by("pk", "collaborator__user_id") + .values_list("pk", "collaborator__user_id") + ), + "profile_links": list( + PartnerProgramUserProfile.objects.filter( + partner_program=program, project_id__in=project_ids + ) + .order_by("pk") + .values_list("pk", "user_id", "project_id") + ), + } + return hashlib.sha256( + json.dumps(state, cls=DjangoJSONEncoder, sort_keys=True).encode() + ).hexdigest() + + +def inspect_action(program, links, action, case_name): + fields = list(program.fields.order_by("pk")) + # Системное имя берем из canonical helper, а не из подписи поля. + case_field = get_program_case_field(program) + if action == "change_case": + if case_field is None: + raise ValidationError({"detail": "В программе не настроены кейсы."}) + try: + validate_case_value(case_field, case_name) + except DjangoValidationError as error: + raise ValidationError({"detail": error.messages[0]}) + project_ids = [link.project_id for link in links] + score_count = ProjectScore.objects.filter( + project_id__in=project_ids, criteria__partner_program=program + ).count() + removed = [] + if action == "change_case": + removed = incompatible_assignments(program, links[0], case_name) + elif action == "detach": + removed = list( + ProjectExpertAssignment.objects.filter( + partner_program=program, project_id__in=project_ids + ).values_list("pk", flat=True) + ) + items, ready, already = [], 0, 0 + for link in links: + errors = submission_errors(program, link, fields) if action == "submit" else [] + if action == "change_case" and any( + value.field_id == case_field.pk and value.value_text == case_name + for value in link.field_values.all() + ): + errors.append("Выберите другой кейс.") + if action == "submit" and link.submitted: + already += 1 + elif not errors: + ready += 1 + items.append({"id": link.pk, "name": link.project.name, "errors": errors}) + result = { + "items": items, + "ready_count": ready, + "already_submitted_count": already, + "score_count": score_count, + "removed_assignments_count": len(removed), + "can_apply": ready > 0 and not any(item["errors"] for item in items), + } + return result, snapshot(program, links, fields), removed, case_field + + +def preview_action(program_id, actor, data): + program = managed_program(program_id, actor) + links = load_links(program, data["ids"]) + result, state, _, _ = inspect_action( + program, links, data["action"], data.get("case_name") + ) + proof = { + "actor": actor.pk, + "program": program.pk, + "ids": data["ids"], + "action": data["action"], + "case": data.get("case_name"), + "state": state, + } + result["preview_token"] = ( + signing.dumps(proof, salt=SALT, compress=True) if result["can_apply"] else None + ) + return result + + +@transaction.atomic +def apply_action(program_id, actor, data): + program = managed_program(program_id, actor) + references = load_links(program, data["ids"]) + project_ids = sorted({link.project_id for link in references}) + # Порядок совпадает с team policy и удалением участников: Project -> Program -> Link. + list( + Project.objects.select_for_update(no_key=True) + .filter(pk__in=project_ids) + .order_by("pk") + ) + program_ids = set( + PartnerProgramProject.objects.filter(project_id__in=project_ids).values_list( + "partner_program_id", flat=True + ) + ) | {program.pk} + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=program_ids) + .order_by("pk") + ) + program = managed_program(program_id, actor) + list( + PartnerProgramProject.objects.select_for_update() + .filter(pk__in=data["ids"]) + .order_by("pk") + ) + links = load_links(program, data["ids"]) + list(program.fields.select_for_update().order_by("pk")) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=600) + except signing.BadSignature: + raise ActionConflict() + expected = { + "actor": actor.pk, + "program": program.pk, + "ids": data["ids"], + "action": data["action"], + "case": data.get("case_name"), + } + if any(proof.get(key) != value for key, value in expected.items()): + raise ActionConflict() + result, state, removed, case_field = inspect_action( + program, links, data["action"], data.get("case_name") + ) + if proof.get("state") != state or not result["can_apply"]: + raise ActionConflict() + now = timezone.now() + if data["action"] == "change_case": + # Явное привилегированное действие; обычные поля сданной заявки остаются заморожены. + existing = PartnerProgramFieldValue.objects.filter( + program_project=links[0], field=case_field + ) + if existing.exists(): + existing.update(value_text=data["case_name"]) + else: + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=links[0], + field=case_field, + value_text=data["case_name"], + ) + ] + ) + ProjectExpertAssignment.objects.filter(pk__in=removed).delete() + PartnerProgramProject.objects.filter(pk__in=data["ids"]).update( + datetime_updated=now + ) + elif data["action"] == "detach": + # Убираем также старый указатель анкеты: он используется автопубликацией. + # Регистрация участника и указатели других программ сохраняются. + PartnerProgramUserProfile.objects.filter( + partner_program=program, project_id__in=project_ids + ).update(project=None) + Invite.objects.filter(program_link_id__in=data["ids"]).delete() + ProjectExpertAssignment.objects.filter(pk__in=removed).delete() + PartnerProgramProject.objects.filter( + partner_program=program, pk__in=data["ids"] + ).delete() + else: + # Только этот endpoint разрешает сдачу после срока, после повторной валидации. + PartnerProgramProject.objects.filter( + partner_program=program, pk__in=data["ids"], submitted=False + ).update(submitted=True, datetime_submitted=now, datetime_updated=now) + logger.info( + "Program project action program=%s actor=%s action=%s links=%s", + program.pk, + actor.pk, + data["action"], + data["ids"], + ) + return { + "changed_count": result["ready_count"], + "already_submitted_count": result["already_submitted_count"], + } diff --git a/partner_programs/tests/test_management_project_actions.py b/partner_programs/tests/test_management_project_actions.py new file mode 100644 index 00000000..db51ab99 --- /dev/null +++ b/partner_programs/tests/test_management_project_actions.py @@ -0,0 +1,547 @@ +from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier, Event + +from unittest.mock import patch +from django.test import TestCase +from django.test import TransactionTestCase, skipUnlessDBFeature +from django.db import close_old_connections, connection, transaction +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import ( + Criteria, + ExpertCaseScope, + ProjectExpertAssignment, + ProjectScore, +) +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator, Project +from partner_programs.services.management_project_actions import ( + ActionConflict, + apply_action, + preview_action, +) + + +class ManagementProjectActionsTests(TestCase): + def setUp(self): + self.program = create_partner_program(is_competitive=True, max_project_rates=3) + self.manager = create_user() + self.program.managers.add(self.manager) + self.case = PartnerProgramField.objects.create( + partner_program=self.program, + name="case", + label="Кейс", + field_type="select", + is_required=True, + show_filter=True, + options="Банк|Город", + ) + self.deck = PartnerProgramField.objects.create( + partner_program=self.program, + name="deck", + label="Презентация", + field_type="file", + is_required=True, + ) + self.link = self.make_link() + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.root = f"/programs/{self.program.pk}/management/projects/actions/" + + def make_link(self): + leader = create_program_member(self.program).user + link = create_program_project(self.program, project=create_project(leader=leader)) + PartnerProgramFieldValue.objects.create( + program_project=link, field=self.case, value_text="Банк" + ) + PartnerProgramFieldValue.objects.create( + program_project=link, + field=self.deck, + value_text="https://example.test/deck.pdf", + ) + return link + + def preview(self, action="submit", ids=None, **kwargs): + return self.client.post( + self.root + "preview/", + {"action": action, "ids": ids or [self.link.pk], **kwargs}, + format="json", + ) + + def apply(self, preview, action="submit", ids=None, **kwargs): + return self.client.post( + self.root + "apply/", + { + "action": action, + "ids": ids or [self.link.pk], + "preview_token": preview.data["preview_token"], + **kwargs, + }, + format="json", + ) + + def test_submit_after_deadline_validates_and_preserves_old_leader_contract(self): + type(self.program).objects.filter(pk=self.program.pk).update( + datetime_project_submission_ends=timezone.now() - timedelta(days=1) + ) + preview = self.preview() + self.assertEqual(preview.status_code, 200, preview.data) + self.assertTrue(preview.data["can_apply"]) + self.assertIn("no-store", preview["Cache-Control"]) + self.assertFalse(PartnerProgramProject.objects.get(pk=self.link.pk).submitted) + response = self.apply(preview) + self.assertEqual(response.status_code, 200, response.data) + self.link.refresh_from_db() + self.assertTrue(self.link.submitted) + self.assertIsNotNone(self.link.datetime_submitted) + other = self.make_link() + from partner_programs.services.project_fields import submit_program_project + from rest_framework.exceptions import ValidationError + + with self.assertRaises(ValidationError): + submit_program_project(other.pk, other.project.leader) + + def test_required_fields_choice_file_and_false_checkbox(self): + self.link.field_values.filter(field=self.deck).delete() + preview = self.preview() + self.assertFalse(preview.data["can_apply"]) + self.assertIn("Презентация", str(preview.data["items"][0]["errors"])) + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=self.link, field=self.deck, value_text="invalid" + ) + ] + ) + self.assertFalse(self.preview().data["can_apply"]) + self.link.field_values.filter(field=self.deck).update( + value_text="https://example.test/deck.pdf" + ) + self.link.field_values.filter(field=self.case).update(value_text="Удаленный кейс") + self.assertFalse(self.preview().data["can_apply"]) + self.link.field_values.filter(field=self.case).update(value_text="Банк") + check = PartnerProgramField.objects.create( + partner_program=self.program, + name="check", + label="Чекбокс", + field_type="checkbox", + is_required=True, + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link, field=check, value_text="false" + ) + self.assertTrue(self.preview().data["can_apply"]) + + def test_bulk_is_atomic_and_already_submitted_dates_unchanged(self): + second = self.make_link() + second.field_values.filter(field=self.deck).delete() + preview = self.preview(ids=[self.link.pk, second.pk]) + self.assertFalse(preview.data["can_apply"]) + self.assertIsNone(preview.data["preview_token"]) + self.assertFalse( + PartnerProgramProject.objects.filter( + pk__in=[self.link.pk, second.pk], submitted=True + ).exists() + ) + PartnerProgramFieldValue.objects.create( + program_project=second, + field=self.deck, + value_text="https://example.test/deck.pdf", + ) + prior = timezone.now() - timedelta(days=2) + PartnerProgramProject.objects.filter(pk=second.pk).update( + submitted=True, datetime_submitted=prior + ) + preview = self.preview(ids=[second.pk, self.link.pk, second.pk]) + self.assertEqual(preview.data["already_submitted_count"], 1) + response = self.apply(preview, ids=[second.pk, self.link.pk]) + self.assertEqual(response.data["changed_count"], 1) + second.refresh_from_db() + self.assertEqual(second.datetime_submitted, prior) + self.assertFalse(self.preview().data["can_apply"]) + + def test_draft_team_and_noncompetitive_block_submission(self): + Project.objects.filter(pk=self.link.project_id).update(draft=True) + self.assertIn("черновике", str(self.preview().data["items"])) + Project.objects.filter(pk=self.link.project_id).update(draft=False) + type(self.program).objects.filter(pk=self.program.pk).update( + legacy_team_min_size=2 + ) + self.assertFalse(self.preview().data["can_apply"]) + + def test_legacy_case_configuration_does_not_bypass_case_validation(self): + PartnerProgramField.objects.filter(pk=self.case.pk).update( + is_required=False, field_type="text" + ) + self.link.field_values.filter(field=self.case).update(value_text="Удаленный кейс") + self.assertFalse(self.preview().data["can_apply"]) + self.link.field_values.filter(field=self.case).delete() + self.assertFalse(self.preview().data["can_apply"]) + type(self.program).objects.filter(pk=self.program.pk).update( + legacy_team_min_size=None, is_competitive=False + ) + self.assertFalse(self.preview().data["can_apply"]) + + def test_case_change_preserves_scores_and_only_removes_incompatible_unscored_assignments( + self, + ): + criterion = Criteria.objects.create( + partner_program=self.program, + name="Баллы", + type="int", + min_value=0, + max_value=10, + ) + scored, incompatible, compatible = [ + create_rate_expert(program=self.program) for _ in range(3) + ] + for user in (scored, incompatible, compatible): + ExpertCaseScope.objects.create( + partner_program=self.program, + expert=user.expert, + all_cases=False, + case_names=["Город"] if user == compatible else ["Банк"], + ) + PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=True) + assignments = [ + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.link.project, + expert=user.expert, + ) + for user in (scored, incompatible) + ] + # Историческое назначение будущего кейса не должно удаляться. + keep = ProjectExpertAssignment( + partner_program=self.program, + project=self.link.project, + expert=compatible.expert, + ) + ProjectExpertAssignment.objects.bulk_create([keep]) + score = ProjectScore.objects.create( + project=self.link.project, criteria=criterion, user=scored, value="7" + ) + preview = self.preview("change_case", case_name="Город") + self.assertEqual(preview.data["score_count"], 1) + self.assertEqual(preview.data["removed_assignments_count"], 1) + response = self.apply(preview, "change_case", case_name="Город") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.link.field_values.get(field=self.case).value_text, "Город") + self.assertTrue(ProjectScore.objects.filter(pk=score.pk, value="7").exists()) + self.assertTrue( + ProjectExpertAssignment.objects.filter(pk=assignments[0].pk).exists() + ) + self.assertFalse( + ProjectExpertAssignment.objects.filter(pk=assignments[1].pk).exists() + ) + self.assertTrue(ProjectExpertAssignment.objects.filter(pk=keep.pk).exists()) + + def test_detach_scored_link_preserves_project_team_scores_and_other_program(self): + member = create_program_member(self.program).user + collaboration = Collaborator.objects.create( + project=self.link.project, user=member + ) + other = create_program_project( + create_partner_program(), project=self.link.project + ) + from partner_programs.models import PartnerProgramUserProfile + + own_profile = PartnerProgramUserProfile.objects.get( + partner_program=self.program, user=self.link.project.leader + ) + PartnerProgramUserProfile.objects.filter(pk=own_profile.pk).update( + project=self.link.project + ) + other_profile = create_program_member( + other.partner_program, + user=self.link.project.leader, + project=self.link.project, + ) + expert = create_rate_expert(program=self.program) + criterion = Criteria.objects.create( + partner_program=self.program, + name="Баллы", + type="int", + min_value=0, + max_value=10, + ) + score = ProjectScore.objects.create( + project=self.link.project, criteria=criterion, user=expert, value="4" + ) + PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=True) + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.link.project, expert=expert.expert + ) + preview = self.preview("detach") + self.assertEqual(preview.data["score_count"], 1) + self.assertEqual(self.apply(preview, "detach").status_code, 200) + self.assertFalse(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertTrue(Project.objects.filter(pk=self.link.project_id).exists()) + self.assertTrue(Collaborator.objects.filter(pk=collaboration.pk).exists()) + self.assertTrue(PartnerProgramProject.objects.filter(pk=other.pk).exists()) + own_profile.refresh_from_db() + other_profile.refresh_from_db() + self.assertIsNone(own_profile.project_id) + self.assertEqual(other_profile.project_id, self.link.project_id) + self.assertTrue(ProjectScore.objects.filter(pk=score.pk).exists()) + self.assertFalse( + ProjectExpertAssignment.objects.filter(pk=assignment.pk).exists() + ) + self.assertFalse( + PartnerProgramFieldValue.objects.filter( + program_project_id=self.link.pk + ).exists() + ) + + def test_permissions_and_program_object_scope(self): + for actor in ( + None, + create_user(), + self.link.project.leader, + create_rate_expert(program=self.program), + ): + self.client.force_authenticate(actor) + self.assertEqual(self.preview().status_code, 401 if actor is None else 403) + response = self.client.post( + self.root + "apply/", + {"action": "submit", "ids": [self.link.pk], "preview_token": "fake"}, + format="json", + ) + self.assertEqual(response.status_code, 401 if actor is None else 403) + self.client.force_authenticate(self.manager) + foreign = create_program_project(create_partner_program()) + self.assertEqual(self.preview(ids=[foreign.pk]).status_code, 404) + self.client.force_authenticate(create_user(is_staff=True)) + self.assertTrue(self.preview().data["can_apply"]) + + def test_token_binds_actor_action_case_ids_and_current_state(self): + preview = self.preview("change_case", case_name="Город") + self.assertEqual(self.apply(preview, "detach").status_code, 409) + self.assertEqual( + self.apply(preview, "change_case", case_name="Банк").status_code, 409 + ) + second = self.make_link() + self.assertEqual( + self.apply( + preview, "change_case", ids=[second.pk], case_name="Город" + ).status_code, + 409, + ) + other = create_user() + self.program.managers.add(other) + self.client.force_authenticate(other) + self.assertEqual( + self.apply(preview, "change_case", case_name="Город").status_code, 409 + ) + self.client.force_authenticate(self.manager) + self.link.field_values.filter(field=self.deck).update( + value_text="https://example.test/new.pdf" + ) + self.assertEqual( + self.apply(preview, "change_case", case_name="Город").status_code, 409 + ) + self.assertEqual(self.link.field_values.get(field=self.case).value_text, "Банк") + + def test_new_score_requires_new_confirmation_and_revoked_manager_is_denied(self): + preview = self.preview("detach") + criterion = Criteria.objects.create( + partner_program=self.program, + name="Баллы", + type="int", + min_value=0, + max_value=10, + ) + ProjectScore.objects.create( + project=self.link.project, + criteria=criterion, + user=create_rate_expert(program=self.program), + value="3", + ) + self.assertEqual(self.apply(preview, "detach").status_code, 409) + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + preview = self.preview("detach") + self.program.managers.remove(self.manager) + self.assertEqual(self.apply(preview, "detach").status_code, 403) + + def test_strict_contract_invalid_case_and_multi_project_changes(self): + self.assertEqual( + self.preview("change_case", case_name="Нет такого").status_code, 400 + ) + self.assertEqual(self.preview("change_case").status_code, 400) + self.assertEqual(self.preview("detach", case_name="Банк").status_code, 400) + self.assertEqual( + self.preview("detach", ids=[self.link.pk, self.make_link().pk]).status_code, + 400, + ) + self.assertEqual( + self.client.post( + self.root + "apply/", + {"action": "submit", "ids": [self.link.pk]}, + format="json", + ).status_code, + 400, + ) + preview = self.preview() + self.assertEqual( + self.client.post( + self.root + "apply/", + { + "action": "submit", + "ids": [self.link.pk], + "preview_token": preview.data["preview_token"] + "bad", + }, + format="json", + ).status_code, + 409, + ) + + def test_expired_confirmation_is_rejected(self): + with patch("django.core.signing.TimestampSigner.timestamp", return_value="0"): + preview = self.preview("detach") + self.assertEqual(self.apply(preview, "detach").status_code, 409) + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + + def test_malformed_body_unknown_fields_and_get_do_not_mutate(self): + for body in ( + [{"action": "submit"}], + {"action": "submit", "ids": [self.link.pk], "force": True}, + ): + self.assertEqual( + self.client.post(self.root + "preview/", body, format="json").status_code, + 400, + ) + self.assertEqual(self.client.get(self.root + "apply/").status_code, 405) + self.link.refresh_from_db() + self.assertFalse(self.link.submitted) + + def test_bulk_checks_records_beyond_first_page(self): + links = [self.link] + [self.make_link() for _ in range(24)] + ids = [link.pk for link in links] + links[-1].field_values.filter(field=self.deck).delete() + preview = self.preview(ids=ids) + self.assertFalse(preview.data["can_apply"]) + self.assertEqual(preview.data["ready_count"], 24) + self.assertFalse( + PartnerProgramProject.objects.filter(pk__in=ids, submitted=True).exists() + ) + PartnerProgramFieldValue.objects.create( + program_project=links[-1], + field=self.deck, + value_text="https://example.test/deck.pdf", + ) + preview = self.preview(ids=ids) + self.assertEqual(self.apply(preview, ids=ids).data["changed_count"], 25) + + +class ProjectActionConcurrencyTests(TransactionTestCase): + def setUp(self): + self.manager = create_user() + self.program = create_partner_program(is_competitive=True) + self.program.managers.add(self.manager) + self.member = create_program_member(self.program) + self.link = create_program_project( + self.program, project=create_project(leader=self.member.user) + ) + + @skipUnlessDBFeature("has_select_for_no_key_update") + def test_score_commits_while_project_locked_and_requires_fresh_confirmation(self): + expert = create_rate_expert(program=self.program) + criterion = Criteria.objects.create( + partner_program=self.program, + name="Баллы", + type="int", + min_value=0, + max_value=10, + ) + body = {"action": "detach", "ids": [self.link.pk]} + preview = preview_action(self.program.pk, self.manager, body) + program_locked, project_locked = Event(), Event() + + def score(): + close_old_connections() + try: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET LOCAL lock_timeout = '3s'") + type(self.program).objects.select_for_update().get(pk=self.program.pk) + program_locked.set() + self.assertTrue(project_locked.wait(timeout=5)) + ProjectScore.objects.create( + project=self.link.project, + criteria=criterion, + user=expert, + value="3", + ) + return "scored" + finally: + close_old_connections() + + def apply(): + close_old_connections() + + def notice(execute, sql, params, many, context): + result = execute(sql, params, many, context) + if 'FROM "projects_project"' in sql and " FOR " in sql: + project_locked.set() + return result + + try: + self.assertTrue(program_locked.wait(timeout=5)) + with connection.execute_wrapper(notice): + try: + apply_action( + self.program.pk, + self.manager, + {**body, "preview_token": preview["preview_token"]}, + ) + return "applied" + except ActionConflict: + return "conflict" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + scoring, applying = pool.submit(score), pool.submit(apply) + self.assertEqual(scoring.result(timeout=10), "scored") + self.assertEqual(applying.result(timeout=10), "conflict") + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertTrue(ProjectScore.objects.filter(project=self.link.project).exists()) + + @skipUnlessDBFeature("has_select_for_no_key_update") + def test_simultaneous_submission_uses_confirmation_only_once(self): + body = {"action": "submit", "ids": [self.link.pk]} + preview = preview_action(self.program.pk, self.manager, body) + barrier = Barrier(2) + + def apply(): + close_old_connections() + try: + barrier.wait(timeout=5) + try: + return apply_action( + self.program.pk, + self.manager, + {**body, "preview_token": preview["preview_token"]}, + )["changed_count"] + except ActionConflict: + return "conflict" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: apply(), range(2))) + self.assertCountEqual(results, [1, "conflict"]) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index f2e00763..8b301b50 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -58,6 +58,8 @@ ManagementParticipantsRemovalView, ManagementProjectsView, ManagementProjectsExportView, + ManagementProjectActionPreviewView, + ManagementProjectActionApplyView, ManagementTeamView, ManagementEvaluationView, ManagementSettingsView, @@ -85,6 +87,14 @@ app_name = "partner_programs" urlpatterns = [ + path( + "/management/projects/actions/preview/", + ManagementProjectActionPreviewView.as_view(), + ), + path( + "/management/projects/actions/apply/", + ManagementProjectActionApplyView.as_view(), + ), path("/manager-overview/export/", ProgramReportView.as_view()), path( "/manager-overview/attention///",