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/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/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..de6013fc 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") @@ -185,6 +190,14 @@ def invitation_data(invitation): } +def expert_invitation_data(invitation): + return { + **invitation_data(invitation), + "all_cases": invitation.all_cases, + "case_names": invitation.case_names, + } + + def program_card(program): return { "id": program.pk, @@ -267,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, @@ -356,17 +369,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 +478,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 +500,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) @@ -494,7 +532,7 @@ def enqueue(): ).update(delivery_status="failed") transaction.on_commit(enqueue) - return invitation_data(invitation) + return expert_invitation_data(invitation) @transaction.atomic @@ -526,6 +564,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..64c933a1 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -26,6 +26,16 @@ 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), + max_length=1000, + required=False, + default=list, + ) + + class RevisionWrite(serializers.Serializer): revision = serializers.CharField(max_length=64) @@ -97,12 +107,28 @@ 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) - return Response(service.invite(pk, request.user, data["email"]), status=201) + data = validated(ExpertInvitationWrite, request.data) + 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) @@ -245,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,