From 335bacfea121c4c535cb2174bafb9e10317d9c2b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 23:26:27 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=B2=D1=8B=D0=B1=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BA=D0=B5=D0=B9=D1=81=D1=8B=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=B5=D1=80=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/management_views.py | 7 +- .../0007_expertinvitation_case_scope.py | 18 +++ project_rates/models.py | 2 + .../tests/test_invitation_case_scope.py | 115 ++++++++++++++++++ project_rates/workspace.py | 57 +++++++-- project_rates/workspace_views.py | 25 +++- 6 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 project_rates/migrations/0007_expertinvitation_case_scope.py create mode 100644 project_rates/tests/test_invitation_case_scope.py diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 4a9789de..6ae99746 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -18,6 +18,7 @@ ManagementMaterialWrite, ) from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.case_analytics import project_case_links from partner_programs.services.management import ( managed_program, management_data, @@ -140,7 +141,11 @@ def get(self, request, pk): [project_data(p, program) for p in page] ) response.data.update( - {"case_options": options, "is_competitive": program.is_competitive} + { + "case_options": options, + "is_competitive": program.is_competitive, + "total_count": project_case_links(program, field=field).count(), + } ) return response diff --git a/project_rates/migrations/0007_expertinvitation_case_scope.py b/project_rates/migrations/0007_expertinvitation_case_scope.py new file mode 100644 index 00000000..80b34c67 --- /dev/null +++ b/project_rates/migrations/0007_expertinvitation_case_scope.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("project_rates", "0006_expertjoinednotification_and_more")] + + operations = [ + migrations.AddField( + model_name="expertinvitation", + name="all_cases", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="expertinvitation", + name="case_names", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 6f38bcf6..f1b4f618 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -272,6 +272,8 @@ class ExpertInvitation(models.Model): PartnerProgram, on_delete=models.CASCADE, related_name="expert_invitations" ) email = models.EmailField() + all_cases = models.BooleanField(default=True) + case_names = models.JSONField(default=list, blank=True) token_digest = models.CharField(max_length=64, unique=True) status = models.CharField( max_length=12, diff --git a/project_rates/tests/test_invitation_case_scope.py b/project_rates/tests/test_invitation_case_scope.py new file mode 100644 index 00000000..435f58d1 --- /dev/null +++ b/project_rates/tests/test_invitation_case_scope.py @@ -0,0 +1,115 @@ +from django.core import mail +from django.test import TestCase, override_settings + +from partner_programs.models import PartnerProgramField +from project_rates.models import ExpertCaseScope, ExpertInvitation +from project_rates.tests.helpers import create_rate_user +from project_rates.tests import test_workspace as fixtures + + +@override_settings( + EXPERT_WORKSPACE_FRONTEND_URL="https://dev.procollab.ru", + EMAIL_USER="no-reply@example.com", + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, + CELERY_BROKER_URL="memory://", + CELERY_RESULT_BACKEND="cache+memory://", + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], +) +class InvitationCaseScopeTests(TestCase): + """The organizer's choice survives delivery, acceptance and later edits.""" + + post = fixtures.WorkspaceTests.post + invitation = fixtures.WorkspaceTests.invitation + + def setUp(self): + fixtures.WorkspaceTests.setUp(self) + self.field = PartnerProgramField.objects.create( + partner_program=self.program, + name="case", + label="Кейс", + field_type="select", + show_filter=True, + options="Сбер|Т-Банк", + is_required=True, + ) + + def new_invitation(self, **scope): + with self.captureOnCommitCallbacks(execute=True): + return self.post( + self.root + "experts/", + { + "email": "invitation-scope@example.com", + **scope, + }, + ) + + def test_invite_requires_explicit_nonempty_valid_scope(self): + for data in ( + {}, + {"all_cases": False, "case_names": []}, + {"all_cases": False, "case_names": ["Чужой кейс"]}, + ): + self.assertEqual(self.new_invitation(**data).status_code, 400) + self.assertFalse(ExpertInvitation.objects.exists()) + self.assertFalse(getattr(mail, "outbox", [])) + + def test_selected_cases_survive_resend_acceptance_and_idempotent_replay(self): + result = self.new_invitation( + all_cases=False, case_names=["Сбер", "Т-Банк", "Сбер"] + ) + self.assertEqual(result.status_code, 201, result.data) + invitation = ExpertInvitation.objects.get() + self.assertFalse(invitation.all_cases) + self.assertEqual(invitation.case_names, ["Сбер", "Т-Банк"]) + with self.captureOnCommitCallbacks(execute=True): + resent = self.post( + self.root + f"expert-invitations/{invitation.pk}/resend/", {} + ) + self.assertEqual(resent.status_code, 200) + invitation.refresh_from_db() + self.assertEqual(invitation.case_names, ["Сбер", "Т-Банк"]) + user = create_rate_user() + user.email = invitation.email + user.save() + self.client.force_authenticate(user) + url = f"/evaluation/own-invitations/{invitation.pk}/accept/" + with self.captureOnCommitCallbacks(execute=True): + self.assertEqual(self.post(url, {}).status_code, 200) + scope = ExpertCaseScope.objects.get( + expert__user=user, partner_program=self.program + ) + self.assertFalse(scope.all_cases) + self.assertEqual(scope.case_names, ["Сбер", "Т-Банк"]) + scope.case_names = ["Т-Банк"] + scope.save() + self.assertEqual(self.post(url, {}).status_code, 200) + scope.refresh_from_db() + self.assertEqual(scope.case_names, ["Т-Банк"]) + + def test_pending_scope_can_be_changed_and_old_revision_is_rejected(self): + invitation, _ = self.invitation() + state = self.client.get(self.root + "experts/").data + url = self.root + f"expert-invitations/{invitation.pk}/cases/" + data = {"revision": state["revision"], "all_cases": False, "case_names": ["Сбер"]} + response = self.post(url, data) + self.assertEqual(response.status_code, 200, response.data) + row = next( + r for r in response.data["experts"] if r.get("invitation_id") == invitation.pk + ) + self.assertEqual(row["case_names"], ["Сбер"]) + self.assertFalse(row["all_cases"]) + self.assertEqual(self.post(url, data).status_code, 409) + data.update( + revision=response.data["revision"], all_cases=True, case_names=["Сбер"] + ) + self.assertEqual(self.post(url, data).status_code, 200) + invitation.refresh_from_db() + self.assertEqual(invitation.case_names, []) + + def test_project_total_does_not_change_with_filters(self): + response = self.client.get(self.root + "projects/", {"search": "no-such-project"}) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["total_count"], 1) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 2860a0ea..9f83b8a8 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -105,6 +105,11 @@ def revision(program): ) ), list(program.fields.filter(name="case").values_list("pk", "options")), + list( + program.expert_invitations.order_by("pk").values_list( + "pk", "status", "all_cases", "case_names" + ) + ), list( project_case_links(program, field=get_program_case_field(program)) .order_by("project_id") @@ -182,6 +187,8 @@ def invitation_data(invitation): "delivery_status": invitation.delivery_status, "expires_at": invitation.expires_at, "created_at": invitation.created_at, + "all_cases": invitation.all_cases, + "case_names": invitation.case_names, } @@ -356,17 +363,37 @@ def save_case_scope(program_id, user, expert_id, data): program = managed_program(program_id, user, lock=True) check_revision(program, data["revision"]) expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + scope = validated_case_scope(program, data) + ExpertCaseScope.objects.update_or_create( + partner_program=program, expert=expert, defaults=scope + ) + return workspace_data(program) + + +def validated_case_scope(program, data, *, require_selection=False): names = data["case_names"] if any(name is not None and name not in program_cases(program) for name in names): raise ValidationError("Выберите кейсы этой программы.") - ExpertCaseScope.objects.update_or_create( - partner_program=program, - expert=expert, - defaults={ - "all_cases": data["all_cases"], - "case_names": list(dict.fromkeys(names)), - }, - ) + if require_selection and not data["all_cases"] and not names: + raise ValidationError("Выберите хотя бы один кейс или все кейсы.") + return { + "all_cases": data["all_cases"], + "case_names": [] if data["all_cases"] else list(dict.fromkeys(names)), + } + + +@transaction.atomic +def save_invitation_case_scope(program_id, user, invitation_id, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + invitation = get_object_or_404(program.expert_invitations, pk=invitation_id) + if invitation.status == "accepted": + raise WorkspaceConflict( + "already_accepted", "Приглашение уже принято. Обновите список экспертов." + ) + scope = validated_case_scope(program, data, require_selection=True) + invitation.all_cases, invitation.case_names = scope["all_cases"], scope["case_names"] + invitation.save(update_fields=["all_cases", "case_names", "updated_at"]) return workspace_data(program) @@ -445,12 +472,17 @@ def token_invitation(token, *, lock=False, allow_accepted=False): @transaction.atomic -def invite(program_id, user, email, *, invitation_id=None): +def invite(program_id, user, email, *, invitation_id=None, scope=None): program = managed_program(program_id, user, lock=True) email = email.strip().lower() if program.experts.filter(user__email__iexact=email).exists(): raise WorkspaceConflict("already_expert", "Этот эксперт уже в программе.") if invitation_id is None: + if scope is None: + if program_cases(program): + raise ValidationError("Выберите кейсы эксперта перед приглашением.") + scope = {"all_cases": True, "case_names": []} + scope = validated_case_scope(program, scope, require_selection=True) previous = program.expert_invitations.filter( email=email, status="pending" ).first() @@ -462,7 +494,7 @@ def invite(program_id, user, email, *, invitation_id=None): previous.status = "revoked" previous.save(update_fields=["status", "updated_at"]) invitation = ExpertInvitation( - partner_program=program, email=email, invited_by=user + partner_program=program, email=email, invited_by=user, **scope ) else: invitation = get_object_or_404(program.expert_invitations, pk=invitation_id) @@ -526,6 +558,11 @@ def accept_invitation(invitation, user): raise InvitationGone() expert, _ = Expert.objects.get_or_create(user=user) expert.programs.add(invitation.partner_program) + ExpertCaseScope.objects.update_or_create( + partner_program=invitation.partner_program, + expert=expert, + defaults={"all_cases": invitation.all_cases, "case_names": invitation.case_names}, + ) invitation.status = "accepted" invitation.accepted_by = user invitation.save(update_fields=["status", "accepted_by", "updated_at"]) diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index a5a13d9e..11acc2de 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -24,6 +24,13 @@ class InvitationThrottle(AnonRateThrottle): class InvitationEmail(serializers.Serializer): email = serializers.EmailField(max_length=254) + all_cases = serializers.BooleanField(required=False) + case_names = serializers.ListField( + child=serializers.CharField(allow_null=True, max_length=1000), + max_length=1000, + required=False, + default=list, + ) class RevisionWrite(serializers.Serializer): @@ -98,11 +105,27 @@ def get(self, request, pk): def post(self, request, pk): data = validated(InvitationEmail, request.data) - return Response(service.invite(pk, request.user, data["email"]), status=201) + scope = ( + {"all_cases": data["all_cases"], "case_names": data["case_names"]} + if "all_cases" in data + else None + ) + return Response( + service.invite(pk, request.user, data["email"], scope=scope), status=201 + ) class InvitationActionView(PrivateView): def post(self, request, pk, invitation_id, action): + if action == "cases": + return Response( + service.save_invitation_case_scope( + pk, + request.user, + invitation_id, + validated(CaseScopeWrite, request.data), + ) + ) if action == "revoke": service.revoke(pk, request.user, invitation_id) return Response(status=204) From 27fd48393cc5c041e9dc3bb38ba458d46e562ef0 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 23:42:58 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D1=80=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=BE?= =?UTF-8?q?=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/tests/test_organizers_api.py | 2 ++ project_rates/workspace.py | 10 ++++++++-- project_rates/workspace_views.py | 7 +++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/partner_programs/tests/test_organizers_api.py b/partner_programs/tests/test_organizers_api.py index 0e179059..ccb5ac31 100644 --- a/partner_programs/tests/test_organizers_api.py +++ b/partner_programs/tests/test_organizers_api.py @@ -102,6 +102,8 @@ def test_invitation_delivers_normalized_email_without_granting_access(self): self.assertEqual(self.program.managers.count(), 1) preview = self.client.get(f"/programs/organizer-invitations/{token}/") self.assertEqual(preview.data["purpose"], "organizer") + self.assertNotIn("all_cases", preview.data) + self.assertNotIn("case_names", preview.data) self.assertFalse(preview.data["account_exists"]) self.assertNotIn("token_digest", str(preview.data)) self.assertEqual(preview["Referrer-Policy"], "no-referrer") diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 9f83b8a8..de6013fc 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -187,6 +187,12 @@ def invitation_data(invitation): "delivery_status": invitation.delivery_status, "expires_at": invitation.expires_at, "created_at": invitation.created_at, + } + + +def expert_invitation_data(invitation): + return { + **invitation_data(invitation), "all_cases": invitation.all_cases, "case_names": invitation.case_names, } @@ -274,7 +280,7 @@ def workspace_data(program): user = invited_account(matches) rows.append( { - **invitation_data(invitation), + **expert_invitation_data(invitation), "id": f"invite-{invitation.pk}", "invitation_id": invitation.pk, "name": person_name(user) if user else None, @@ -526,7 +532,7 @@ def enqueue(): ).update(delivery_status="failed") transaction.on_commit(enqueue) - return invitation_data(invitation) + return expert_invitation_data(invitation) @transaction.atomic diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index 11acc2de..64c933a1 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -24,6 +24,9 @@ class InvitationThrottle(AnonRateThrottle): class InvitationEmail(serializers.Serializer): email = serializers.EmailField(max_length=254) + + +class ExpertInvitationWrite(InvitationEmail): all_cases = serializers.BooleanField(required=False) case_names = serializers.ListField( child=serializers.CharField(allow_null=True, max_length=1000), @@ -104,7 +107,7 @@ def get(self, request, pk): return Response(service.workspace_data(managed_program(pk, request.user))) def post(self, request, pk): - data = validated(InvitationEmail, request.data) + data = validated(ExpertInvitationWrite, request.data) scope = ( {"all_cases": data["all_cases"], "case_names": data["case_names"]} if "all_cases" in data @@ -268,7 +271,7 @@ def get(self, request, token): account = service.invited_account(accounts) return Response( { - **service.invitation_data(invitation), + **service.expert_invitation_data(invitation), "program": service.program_card(invitation.partner_program), "account_exists": bool(accounts), "login_email": account.email if account else invitation.email,