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
1 change: 1 addition & 0 deletions partner_programs/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ class PartnerProgramsConfig(AppConfig):
verbose_name = "Партнерские программы"

def ready(self):
from partner_programs import organizer_signals # noqa: F401
from partner_programs import solution_file_signals # noqa: F401
from partner_programs import solution_lifecycle_signals # noqa: F401
34 changes: 34 additions & 0 deletions partner_programs/migrations/0044_organizer_expert_identities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.db import migrations


def create_organizer_expert_identities(apps, schema_editor):
program = apps.get_model("partner_programs", "PartnerProgram")
expert = apps.get_model("users", "Expert")
db = schema_editor.connection.alias
organizers = (
program.managers.through.objects.using(db)
.order_by("customuser_id")
.values_list("customuser_id", flat=True)
.distinct()
.iterator(chunk_size=1000)
)
batch = []
for user_id in organizers:
batch.append(expert(user_id=user_id))
if len(batch) == 1000:
expert.objects.using(db).bulk_create(batch, ignore_conflicts=True)
batch.clear()
if batch:
expert.objects.using(db).bulk_create(batch, ignore_conflicts=True)


class Migration(migrations.Migration):
dependencies = [
("partner_programs", "0043_project_copy_form"),
("users", "0066_alter_userachievement_files"),
]

operations = [
# Keep identities on rollback because manual assignments can reference them.
migrations.RunPython(create_organizer_expert_identities, migrations.RunPython.noop),
]
18 changes: 18 additions & 0 deletions partner_programs/organizer_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver

from partner_programs.models import PartnerProgram
from users.models import Expert


@receiver(m2m_changed, sender=PartnerProgram.managers.through)
def ensure_organizer_expert_identity(sender, instance, action, reverse, pk_set, using, **kwargs):
if action != "post_add" or not pk_set:
return
user_ids = {instance.pk} if reverse else pk_set
# Assignments need an Expert identity. The manager relation grants access;
# Expert.programs remains the record of independently granted expertise.
Expert.objects.using(using).bulk_create(
[Expert(user_id=user_id) for user_id in sorted(user_ids)],
ignore_conflicts=True,
)
7 changes: 4 additions & 3 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from projects.models import Project
from projects.validators import validate_project
from project_rates.program_experts import can_evaluate_program

from .fields import PartnerProgramFieldValueUpdateSerializer

Expand Down Expand Up @@ -146,9 +147,7 @@ def get_is_user_expert(self, program):
user = self.context.get("user") or getattr(
self.context.get("request"), "user", None
)
return bool(
user and user.is_authenticated and program.experts.filter(user=user).exists()
)
return can_evaluate_program(user, program.pk)

def get_pending_expert_invitation_id(self, program):
from django.utils import timezone
Expand All @@ -158,6 +157,8 @@ def get_pending_expert_invitation_id(self, program):
)
if not user or not user.is_authenticated or not user.is_active:
return None
if program.experts.filter(user_id=user.pk).exists():
return None
return (
program.expert_invitations.filter(
email__iexact=user.email, status="pending", expires_at__gt=timezone.now()
Expand Down
3 changes: 2 additions & 1 deletion partner_programs/services/attention_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from partner_programs.services.assignment_analytics import annotated_assignment_queryset
from partner_programs.services.exports import _literal_excel_cell
from partner_programs.services.management import managed_program, person_name
from project_rates.program_experts import program_experts
from partner_programs.services.solution_attention import (
SOLUTION_AWAITING,
solution_assignment_sources,
Expand Down Expand Up @@ -114,7 +115,7 @@ def recipient_targets(program, category, rows):
project_id__in=links,
project_submitted=True,
is_completed=False,
expert__programs=program.pk,
expert_id__in=program_experts(program.pk).values("pk"),
)
.order_by("pk")
.values("pk", "project_id", "expert__user_id")
Expand Down
4 changes: 2 additions & 2 deletions partner_programs/services/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from partner_programs.services.analytics import _solution_rows
from partner_programs.services.case_analytics import project_case_links
from project_rates.models import Criteria, ProjectScore
from project_rates.program_experts import can_evaluate_program
from project_rates.services import get_projects_for_rate_queryset
from partner_programs.services.assignment_analytics import (
assignment_rows,
Expand Down Expand Up @@ -288,8 +289,7 @@ def evaluation_data(program, link, user):
eligible = (
program.is_competitive
and link.submitted
and user.user_type == 3
and program.experts.filter(user_id=user.pk).exists()
and can_evaluate_program(user, program.pk)
)
can_rate = bool(
eligible
Expand Down
4 changes: 2 additions & 2 deletions partner_programs/services/solution_attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from partner_programs.serializers.attention_actions import MAX_SELECTION
from partner_programs.services.solution_analytics import SolutionAnalyticsContext
from partner_programs.solution_models import ProgramSolution
from project_rates.program_experts import program_experts
from project_rates.solution_models import SolutionExpertAssignment

SOLUTION_AWAITING = "solutions-awaiting-evaluation"
Expand Down Expand Up @@ -68,8 +69,7 @@ def solution_recipient_targets(program, rows):
solution__program=program,
solution__status="submitted",
solution__archived_at__isnull=True,
expert__programs=program,
expert__user__is_active=True,
expert_id__in=program_experts(program.pk).values("pk"),
)
.order_by("pk")
.values("pk", "solution_id", "expert__user_id")
Expand Down
13 changes: 8 additions & 5 deletions partner_programs/tests/test_management_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,14 @@ def test_existing_manager_expert_can_rate_only_assigned_submitted_project(self):
unsubmitted = create_program_project(
self.program, project=create_project(leader=self.member.user), submitted=False
)
ProjectExpertAssignment.objects.create(
partner_program=self.program,
project=unsubmitted.project,
expert=expert.expert,
)
# A previously assigned project may subsequently become unsubmitted.
ProjectExpertAssignment.objects.bulk_create([
ProjectExpertAssignment(
partner_program=self.program,
project=unsubmitted.project,
expert=expert.expert,
),
])
self.assertFalse(
self.client.get(
self.url("management-evaluation", link_id=unsubmitted.pk)
Expand Down
24 changes: 24 additions & 0 deletions partner_programs/tests/test_optional_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient

from partner_programs.models import PartnerProgram
from partner_programs.tests.helpers import create_user
from project_rates.program_experts import can_evaluate_program
from project_rates.tests.helpers import create_rate_user


@override_settings(STORAGES={
Expand Down Expand Up @@ -53,3 +56,24 @@ def test_existing_nonempty_tag_remains_unchanged(self):
self.assertEqual(self.client.post(self.url, data).status_code, 302)
program = PartnerProgram.objects.get(name=data["name"])
self.assertEqual(program.tag, data["tag"])

def test_admin_manager_selection_uses_live_evaluation_access(self):
organizer = create_rate_user(prefix="admin-added-organizer")
data = self.payload()
data["managers"] = [organizer.pk]
self.assertEqual(self.client.post(self.url, data).status_code, 302)
program = PartnerProgram.objects.get(name=data["name"])
self.assertFalse(can_evaluate_program(organizer, program.pk))

now = timezone.now()
program.draft = False
program.datetime_started = now
program.datetime_registration_ends = now + timezone.timedelta(days=1)
program.datetime_finished = now + timezone.timedelta(days=2)
program.save()
self.assertTrue(can_evaluate_program(organizer, program.pk))
self.assertFalse(organizer.expert.programs.exists())

organizer.expert.programs.add(program)
program.managers.remove(organizer)
self.assertTrue(can_evaluate_program(organizer, program.pk))
42 changes: 42 additions & 0 deletions partner_programs/tests/test_organizer_evaluation_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from importlib import import_module
from types import SimpleNamespace

from django.apps import apps
from django.db import connection
from django.test import TestCase

from partner_programs.models import PartnerProgram
from project_rates.program_experts import can_evaluate_program, program_experts
from project_rates.tests.helpers import create_rate_expert, create_rate_program, create_rate_user
from users.models import CustomUser, Expert


class OrganizerEvaluationBackfillTests(TestCase):
def test_existing_manager_is_backfilled_without_changing_roles_or_independent_grants(self):
program = create_rate_program()
organizer = create_rate_user()
independent = create_rate_expert(program=program)
original_expert_id = independent.expert.pk
outsider = create_rate_user()
# Historical through-table rows did not provision Expert identities.
relation = PartnerProgram.managers.through
relation.objects.bulk_create(
[relation(partnerprogram=program, customuser=user) for user in (organizer, independent)]
)
self.assertTrue(can_evaluate_program(organizer, program.pk))
self.assertFalse(Expert.objects.filter(user=organizer).exists())

migration = import_module("partner_programs.migrations.0044_organizer_expert_identities")
editor = SimpleNamespace(connection=connection)
migration.create_organizer_expert_identities(apps, editor)
migration.create_organizer_expert_identities(apps, editor)

self.assertCountEqual(
program_experts(program.pk).values_list("user_id", flat=True),
[organizer.pk, independent.pk],
)
organizer.refresh_from_db()
self.assertEqual(organizer.user_type, CustomUser.MEMBER)
self.assertFalse(organizer.expert.programs.exists())
self.assertTrue(program.experts.filter(pk=original_expert_id).exists())
self.assertFalse(Expert.objects.filter(user=outsider).exists())
14 changes: 11 additions & 3 deletions partner_programs/tests/test_organizers_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,25 @@ def test_wrong_account_cannot_accept(self):
invitation.refresh_from_db()
self.assertEqual(invitation.status, "pending")

def test_acceptance_is_explicit_idempotent_program_scoped_and_not_expert(self):
def test_acceptance_grants_evaluation_only_in_this_program_and_keeps_primary_role(self):
invitation, token = self.invite(self.colleague.email)
self.client.force_authenticate(self.colleague)
url = f"/programs/organizer-invitations/{token}/"
self.assertEqual(self.client.get(url).status_code, 200)
self.assertFalse(self.program.is_manager(self.colleague))
self.assertFalse(self.client.get("/auth/users/current/").data["has_evaluation_access"])
self.assertEqual(self.post(url + "accept/", {}).status_code, 200)
self.assertEqual(self.post(url + "accept/", {}).status_code, 200)
self.assertTrue(self.program.is_manager(self.colleague))
self.assertFalse(self.other.is_manager(self.colleague))
self.assertFalse(Expert.objects.filter(user=self.colleague).exists())
profile = self.client.get("/auth/users/current/").data
self.assertEqual(profile["user_type"], CustomUser.MEMBER)
self.assertTrue(profile["has_evaluation_access"])
self.assertFalse(Expert.objects.get(user=self.colleague).programs.exists())
self.assertTrue(self.client.get(f"/programs/{self.program.pk}/").data["is_user_expert"])
self.assertFalse(self.client.get(f"/programs/{self.other.pk}/").data["is_user_expert"])
rows = self.client.get("/evaluation/programs/").data["results"]
self.assertEqual([(row["id"], row["assigned"]) for row in rows], [(self.program.pk, 0)])
self.assertEqual(self.client.get(self.root + "organizers/").status_code, 200)
self.program.refresh_from_db()
self.assertIsNone(self.program.required_evaluators)
Expand Down Expand Up @@ -173,7 +181,7 @@ def test_registration_requires_birthday_and_preserves_normal_role(self):
self.assertFalse(user.is_staff)
self.assertFalse(user.is_superuser)
self.assertEqual(user.user_type, CustomUser.MEMBER)
self.assertFalse(Expert.objects.filter(user=user).exists())
self.assertFalse(Expert.objects.get(user=user).programs.exists())
self.assertIn("access", response.data)

def test_registration_does_not_replace_existing_account(self):
Expand Down
4 changes: 0 additions & 4 deletions partner_programs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,6 @@ def get(self, request, *args, **kwargs):
data["solution_action_blocked_reason"] = (
None if available else solution_context["blocked_reason"]
)
data["is_user_expert"] = bool(
request.user.is_authenticated
and program.experts.filter(user_id=request.user.pk).exists()
)
if request.user.is_authenticated:
add_view(program, request.user)
return Response(data, status=status.HTTP_200_OK)
Expand Down
21 changes: 18 additions & 3 deletions project_rates/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.shortcuts import redirect
from django.urls import reverse
from rest_framework.exceptions import APIException

from partner_programs.models import PartnerProgramProject
from projects.models import Project
from users.models import Expert

from .models import Criteria, ProjectExpertAssignment, ProjectScore
from .program_experts import program_experts
from .solution_admin import ProgramLockAdmin, safe_id
from .criteria_policy import locked
from . import solution_admin # noqa: F401

Expand Down Expand Up @@ -112,7 +115,7 @@ def __init__(self, *args, **kwargs):

if program_id:
self.fields["expert"].queryset = (
Expert.objects.filter(programs__id=program_id)
program_experts(program_id)
.select_related("user")
.order_by("user__last_name", "user__first_name")
)
Expand All @@ -134,7 +137,7 @@ def clean(self):
if not program or not expert or not projects:
return cleaned_data

if not expert.programs.filter(id=program.id).exists():
if not program_experts(program.pk).filter(pk=expert.pk).exists():
self.add_error("expert", "Эксперт не состоит в выбранной программе.")

linked_project_ids = set(
Expand Down Expand Up @@ -178,11 +181,20 @@ def clean(self):
"Нет проектов для нового назначения: все уже назначены или достигли лимита."
)

if program.is_manager(expert.user):
from project_rates.workspace import validate_organizer_project_assignments

try:
validate_organizer_project_assignments(program, expert, actionable)
except APIException as exc:
detail = exc.detail.get("detail") if isinstance(exc.detail, dict) else exc.detail[0]
self.add_error("projects", detail)

return cleaned_data


@admin.register(ProjectExpertAssignment)
class ProjectExpertAssignmentAdmin(admin.ModelAdmin):
class ProjectExpertAssignmentAdmin(ProgramLockAdmin):
list_display = (
"id",
"partner_program",
Expand All @@ -199,6 +211,9 @@ class ProjectExpertAssignmentAdmin(admin.ModelAdmin):
"expert__user__email",
)

def post_program_id(self, request, obj):
return obj.partner_program_id if obj else safe_id(request.POST.get("partner_program"))

def get_form(self, request, obj=None, **kwargs):
if obj is None:
kwargs["form"] = ProjectExpertAssignmentBulkAddForm
Expand Down
4 changes: 3 additions & 1 deletion project_rates/expert_cabinet.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
from partner_programs.services.case_analytics import project_case_links
from partner_programs.services.case_fields import get_program_case_field
from project_rates.models import ProjectScore
from project_rates.program_experts import evaluation_programs
from projects.models import Collaborator


def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications=False):
own = annotated_assignment_queryset().filter(
expert__user=user,
partner_program__experts__user=user,
partner_program_id__in=program_ids,
).filter(
partner_program_id__in=evaluation_programs(user).values("pk"),
)
linked = PartnerProgramProject.objects.filter(
partner_program_id=OuterRef("partner_program_id"),
Expand Down
Loading
Loading