Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions partner_programs/tests/test_organizers_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions project_rates/migrations/0007_expertinvitation_case_scope.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions project_rates/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
115 changes: 115 additions & 0 deletions project_rates/tests/test_invitation_case_scope.py
Original file line number Diff line number Diff line change
@@ -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="[email protected]",
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": "[email protected]",
**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)
67 changes: 55 additions & 12 deletions project_rates/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
32 changes: 29 additions & 3 deletions project_rates/workspace_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading