diff --git a/partner_programs/apps.py b/partner_programs/apps.py index 04acbf70..fdd306a6 100644 --- a/partner_programs/apps.py +++ b/partner_programs/apps.py @@ -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 diff --git a/partner_programs/migrations/0044_organizer_expert_identities.py b/partner_programs/migrations/0044_organizer_expert_identities.py new file mode 100644 index 00000000..1d8dd716 --- /dev/null +++ b/partner_programs/migrations/0044_organizer_expert_identities.py @@ -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), + ] diff --git a/partner_programs/organizer_signals.py b/partner_programs/organizer_signals.py new file mode 100644 index 00000000..56522d84 --- /dev/null +++ b/partner_programs/organizer_signals.py @@ -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, + ) diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index b535a926..98e67cc4 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -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 @@ -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 @@ -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() diff --git a/partner_programs/services/attention_actions.py b/partner_programs/services/attention_actions.py index 35076b69..242465ef 100644 --- a/partner_programs/services/attention_actions.py +++ b/partner_programs/services/attention_actions.py @@ -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, @@ -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") diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index b87fd020..f4c295c6 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -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, @@ -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 diff --git a/partner_programs/services/solution_attention.py b/partner_programs/services/solution_attention.py index 95fb1bad..49093f93 100644 --- a/partner_programs/services/solution_attention.py +++ b/partner_programs/services/solution_attention.py @@ -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" @@ -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") diff --git a/partner_programs/tests/test_management_api.py b/partner_programs/tests/test_management_api.py index 9e0e1345..06784860 100644 --- a/partner_programs/tests/test_management_api.py +++ b/partner_programs/tests/test_management_api.py @@ -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) diff --git a/partner_programs/tests/test_optional_tag.py b/partner_programs/tests/test_optional_tag.py index b5118db6..894bb35f 100644 --- a/partner_programs/tests/test_optional_tag.py +++ b/partner_programs/tests/test_optional_tag.py @@ -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={ @@ -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)) diff --git a/partner_programs/tests/test_organizer_evaluation_migration.py b/partner_programs/tests/test_organizer_evaluation_migration.py new file mode 100644 index 00000000..9cfd44bb --- /dev/null +++ b/partner_programs/tests/test_organizer_evaluation_migration.py @@ -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()) diff --git a/partner_programs/tests/test_organizers_api.py b/partner_programs/tests/test_organizers_api.py index ccb5ac31..e7f5b313 100644 --- a/partner_programs/tests/test_organizers_api.py +++ b/partner_programs/tests/test_organizers_api.py @@ -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) @@ -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): diff --git a/partner_programs/views.py b/partner_programs/views.py index 8759c5d6..7a43aa0d 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -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) diff --git a/project_rates/admin.py b/project_rates/admin.py index 4b204cbc..f744bd21 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -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 @@ -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") ) @@ -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( @@ -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", @@ -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 diff --git a/project_rates/expert_cabinet.py b/project_rates/expert_cabinet.py index 37a05b53..201d66ca 100644 --- a/project_rates/expert_cabinet.py +++ b/project_rates/expert_cabinet.py @@ -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"), diff --git a/project_rates/models.py b/project_rates/models.py index 91686007..823667a1 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -195,14 +195,17 @@ def has_scores(self) -> bool: ).exists() def clean(self): + from rest_framework.exceptions import APIException + + from project_rates.program_experts import program_experts + errors = {} - if ( - self.expert_id - and self.partner_program_id - and not self.expert.programs.filter(id=self.partner_program_id).exists() - ): - errors["expert"] = "Эксперт не состоит в указанной программе." + expert = None + if self.expert_id and self.partner_program_id: + expert = program_experts(self.partner_program_id).filter(pk=self.expert_id).first() + if expert is None: + errors["expert"] = "Эксперт не состоит в указанной программе." if ( self.project_id @@ -214,6 +217,21 @@ def clean(self): ): errors["project"] = "Проект не привязан к указанной программе." + if not errors and expert and expert.is_organizer and self.project_id: + previous = type(self).objects.filter(pk=self.pk).values_list( + "partner_program_id", "project_id", "expert_id" + ).first() + if previous != (self.partner_program_id, self.project_id, self.expert_id): + from project_rates.workspace import validate_organizer_project_assignments + + try: + validate_organizer_project_assignments( + self.partner_program, expert, [self.project_id] + ) + except APIException as exc: + detail = exc.detail.get("detail") if isinstance(exc.detail, dict) else exc.detail[0] + errors["project"] = detail + if self.partner_program_id and self.project_id: max_rates = self.partner_program.max_project_rates if max_rates and self.partner_program.required_evaluators is None: diff --git a/project_rates/program_experts.py b/project_rates/program_experts.py new file mode 100644 index 00000000..b422779d --- /dev/null +++ b/project_rates/program_experts.py @@ -0,0 +1,51 @@ +from django.db.models import Exists, OuterRef, Q + +from partner_programs.models import PartnerProgram +from partner_programs.permissions import active_programs +from users.models import Expert + + +ORGANIZER_EVALUATION = Q(draft=False) & ~Q(program_format="noncompetitive") + + +def program_experts(program_id, *, active_only=True): + """Expert identities eligible for manual assignment in this program.""" + organizers = PartnerProgram.managers.through.objects.filter( + partnerprogram_id=program_id, customuser_id=OuterRef("user_id") + ) + independent = Expert.programs.through.objects.filter( + partnerprogram_id=program_id, expert_id=OuterRef("pk") + ) + eligible_program = PartnerProgram.objects.filter(ORGANIZER_EVALUATION, pk=program_id) + experts = Expert.objects.annotate(is_organizer=Exists(organizers)).filter( + (Q(is_organizer=True) & Exists(active_programs(eligible_program))) + | Exists(independent) + ) + return experts.filter(user__is_active=True) if active_only else experts + + +def evaluation_programs(user): + if not user or not user.is_authenticated or not user.is_active: + return PartnerProgram.objects.none() + independent = Expert.programs.through.objects.filter( + partnerprogram_id=OuterRef("pk"), expert__user_id=user.pk + ) + organizes = PartnerProgram.managers.through.objects.filter( + partnerprogram_id=OuterRef("pk"), customuser_id=user.pk + ) + return active_programs( + PartnerProgram.objects.filter( + Q(Exists(independent)) | (Q(Exists(organizes)) & ORGANIZER_EVALUATION) + ) + ) + + +def can_evaluate_program(user, program_id): + return evaluation_programs(user).filter(pk=program_id).exists() + + +def has_evaluation_role(user): + return bool( + user and user.is_authenticated and user.is_active + and (evaluation_programs(user).exists() or Expert.objects.filter(user=user).exists()) + ) diff --git a/project_rates/services.py b/project_rates/services.py index e77bff0d..854c5324 100644 --- a/project_rates/services.py +++ b/project_rates/services.py @@ -13,16 +13,16 @@ ) from django.utils import timezone -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from partner_programs.models import PartnerProgram, PartnerProgramProject from partner_programs.serializers import ProgramProjectFilterRequestSerializer from partner_programs.utils import filter_program_projects_by_field_name -from projects.models import Project +from projects.models import Collaborator, Project from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.program_experts import can_evaluate_program from project_rates.serializers import ProjectScoreCreateSerializer from project_rates.criteria_policy import CriteriaConflict, mark_locked, revision -from users.models import Expert from vacancy.mapping import ProjectRatedParams, MessageTypeEnum from vacancy.tasks import send_email @@ -155,7 +155,8 @@ def submit_project_scores(*, user, project_id: int, data) -> None: ) raise ValueError("All criteria must belong to the same program") program = PartnerProgram.objects.select_for_update().get(pk=programs.pop()) - Expert.objects.get(user__id=user.id, programs=program) + if not can_evaluate_program(user, program.pk): + raise PermissionDenied("Оценивание доступно экспертам этой программы.") if ( expected_revision is not None or program.evaluation_criteria_version ) and expected_revision != revision(program): @@ -219,17 +220,14 @@ def _prepare_project_score_data( raise ValueError("All criteria must belong to the same program") program = criteria_qs.first().partner_program - Expert.objects.get(user__id=user.id, programs=program) + is_organizer = program.is_manager(user) + if (program.required_evaluators is not None or is_organizer) and ( + program.datetime_evaluation_ends + and timezone.now() > program.datetime_evaluation_ends + ): + raise ValidationError("Срок оценивания завершен.") if program.required_evaluators is not None: - from projects.models import Collaborator - - if ( - program.datetime_evaluation_ends - and timezone.now() > program.datetime_evaluation_ends - ): - raise ValidationError("Срок оценивания завершен.") - required = set(program.criterias.exclude(type="str").values_list("pk", flat=True)) if not required.issubset(criteria_ids): raise ValidationError( @@ -239,17 +237,18 @@ def _prepare_project_score_data( partner_program=program, project_id=project_id, submitted=True ).exists(): raise ValidationError("Сначала нужно сдать решение.") - if ( - Project.objects.filter(pk=project_id, leader=user).exists() - or Collaborator.objects.filter(project_id=project_id, user=user).exists() - ): - raise ValidationError("Нельзя оценивать проект своей команды.") # Optional comments are stored too, keeping completion and legacy analytics consistent. present = set(criteria_ids) for criterion in program.criterias.filter(type="str").exclude(pk__in=present): rating_data.append({"criterion_id": criterion.pk, "value": ""}) criteria_ids.append(criterion.pk) + if (program.required_evaluators is not None or is_organizer) and ( + Project.objects.filter(pk=project_id, leader=user).exists() + or Collaborator.objects.filter(project_id=project_id, user=user).exists() + ): + raise ValidationError("Нельзя оценивать проект своей команды.") + for criterion in rating_data: criterion["user"] = user.id criterion["project"] = project_id diff --git a/project_rates/solution_admin.py b/project_rates/solution_admin.py index 91776310..41dabd1b 100644 --- a/project_rates/solution_admin.py +++ b/project_rates/solution_admin.py @@ -12,6 +12,7 @@ from partner_programs.solution_errors import MAX_ID from partner_programs.solution_models import ProgramSolution from project_rates.models import Criteria +from project_rates.program_experts import program_experts from project_rates.solution_models import SolutionScore, SolutionExpertAssignment @@ -69,9 +70,7 @@ def __init__(self, *args, **kwargs): self.fields["solutions"].queryset = self.fields["solutions"].queryset.filter( program_id=program_id ) - self.fields["expert"].queryset = self.fields["expert"].queryset.filter( - programs__pk=program_id, user__is_active=True - ) + self.fields["expert"].queryset = program_experts(program_id) def clean(self): data = super().clean() diff --git a/project_rates/solution_allocation.py b/project_rates/solution_allocation.py index 948b8347..fbd413e1 100644 --- a/project_rates/solution_allocation.py +++ b/project_rates/solution_allocation.py @@ -14,10 +14,11 @@ from project_rates.allocation_alternatives import alternative_pairs from project_rates.evaluation_math import complete_values from project_rates.models import Criteria +from project_rates.program_experts import program_experts from project_rates.solution_assignments import check_revision, revision, enable_workspace from project_rates.solution_evaluation import snapshot_case, snapshot_name, team_user_ids from project_rates.solution_models import SolutionExpertAssignment, SolutionScore -from project_rates.workspace import eligible, target +from project_rates.workspace import eligible, expert_rows, target SALT = "solution-expert-allocation" @@ -30,30 +31,35 @@ def require_mode(program): def assignment_snapshot(program, *, include_historical=True): - pairs = set( - program.solution_expert_assignments.values_list("solution_id", "expert_id") - ) - members = dict(program.experts.values_list("user_id", "pk")) + pairs = { + (sid, eid): uid + for sid, eid, uid in program.solution_expert_assignments.values_list( + "solution_id", "expert_id", "expert__user_id" + ) + } values = defaultdict(dict) for solution_id, user_id, criterion_id, value in SolutionScore.objects.filter( criteria__partner_program=program, solution__program=program, solution__archived_at__isnull=True, ).values_list("solution_id", "user_id", "criteria_id", "value"): - if user_id in members: - pair = (solution_id, members[user_id]) - values[pair][criterion_id] = value - # Предпросмотр учитывает те же исторические назначения, что явный opt-in. - if include_historical and program.required_evaluators is None: - pairs.add(pair) + values[solution_id, user_id][criterion_id] = value + if include_historical and program.required_evaluators is None: + # Предпросмотр учитывает те же исторические назначения, что явный opt-in. + members = dict( + program_experts(program.pk, active_only=False).values_list("user_id", "pk") + ) + for solution_id, user_id in values: + if user_id in members: + pairs[solution_id, members[user_id]] = user_id criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) return [ dict( solution_id=sid, expert_id=eid, - is_completed=complete_values(criteria, values[(sid, eid)]), + is_completed=complete_values(criteria, values.get((sid, uid), {})), ) - for sid, eid in sorted(pairs) + for (sid, eid), uid in sorted(pairs.items()) ] @@ -74,10 +80,8 @@ def load_plan(token, program): def allocation(program, data): require_mode(program) - check_revision(program, data["revision"]) - all_experts = list( - program.experts.filter(user__is_active=True).select_related("user").order_by("pk") - ) + rev = check_revision(program, data["revision"]) + all_experts = expert_rows(program) selected = set(data["expert_ids"]) if (not selected and "case_pools" not in data) or selected - { e.pk for e in all_experts @@ -87,6 +91,7 @@ def allocation(program, data): "Выберите активных экспертов этой программы.", status=400, ) + organizers = {e.pk for e in all_experts if e.is_organizer} scopes = {scope.expert_id: scope for scope in program.expert_case_scopes.all()} field = get_program_case_field(program) cases = field.get_options_list() if field else [] @@ -94,7 +99,8 @@ def allocation(program, data): if "case_pools" in data: pools = {} for pool in data["case_pools"]: - name, members = pool["case_name"], set(pool["expert_ids"]) + name = pool["case_name"] + members = set(pool["expert_ids"]) - organizers if name in pools or (name is not None and name not in cases): raise SolutionError( "invalid_assignments", @@ -110,7 +116,11 @@ def allocation(program, data): status=400, ) pools[name] = members - experts = [expert for expert in all_experts if expert.pk in selected] + experts = [ + expert + for expert in all_experts + if expert.pk in selected and expert.pk not in organizers + ] solutions = list(program.solutions.filter(status="submitted").order_by("pk")) if "solution_ids" in data: selected_solutions = set(data["solution_ids"]) @@ -245,7 +255,6 @@ def allocation(program, data): pairs = {(row["solution_id"], row["expert_id"]) for row in additions} added = Counter(eid for _, eid in pairs) remaining = before + added - rev = revision(program) token = signing.dumps( dict( program_id=program.pk, diff --git a/project_rates/solution_assignments.py b/project_rates/solution_assignments.py index 6c6631f9..432bca90 100644 --- a/project_rates/solution_assignments.py +++ b/project_rates/solution_assignments.py @@ -13,6 +13,7 @@ from partner_programs.solution_team import program_for from project_rates.criteria_policy import revision as criteria_revision from project_rates.models import Criteria +from project_rates.program_experts import program_experts from project_rates.solution_models import SolutionExpertAssignment, SolutionScore from project_rates.solution_evaluation import ( snapshot_case, @@ -36,7 +37,9 @@ def revision(program): ) ), list( - program.experts.order_by("pk").values_list("pk", "user_id", "user__is_active") + program_experts(program.pk, active_only=False) + .order_by("pk") + .values_list("pk", "user_id", "user__is_active", "is_organizer") ), list( program.solution_expert_assignments.order_by("pk").values_list( @@ -73,15 +76,14 @@ def check_revision(program, expected): "Данные изменились. Обновите список назначений.", meta={"current_assignment_revision": actual}, ) + return actual def expert_for(program, expert_id): if not 0 < expert_id <= 2**53 - 1: raise Http404 expert = ( - program.experts.filter(pk=expert_id, user__is_active=True) - .select_related("user") - .first() + program_experts(program.pk).filter(pk=expert_id).select_related("user").first() ) if expert is None: raise Http404 @@ -140,7 +142,9 @@ def enable_workspace(program): "datetime_updated", ] ) - members = dict(program.experts.values_list("user_id", "pk")) + members = dict( + program_experts(program.pk, active_only=False).values_list("user_id", "pk") + ) historical = ( SolutionScore.objects.filter( criteria__partner_program=program, diff --git a/project_rates/solution_cabinet.py b/project_rates/solution_cabinet.py index 5b04576c..6d51fc58 100644 --- a/project_rates/solution_cabinet.py +++ b/project_rates/solution_cabinet.py @@ -4,18 +4,16 @@ from django.utils.dateparse import parse_datetime -from partner_programs.models import PartnerProgram from partner_programs.solution_api import iso from project_rates.evaluation_math import complete_values from project_rates.expert_cabinet import cabinet_projects from project_rates.models import Criteria +from project_rates.program_experts import evaluation_programs from project_rates.solution_evaluation import metrics, rating_summary def cabinet_applications(user, query): - programs = list( - PartnerProgram.objects.filter(experts__user=user).distinct().order_by("pk") - ) + programs = list(evaluation_programs(user).order_by("pk")) legacy = cabinet_projects( user, query, diff --git a/project_rates/solution_evaluation.py b/project_rates/solution_evaluation.py index 8a348b7a..56f978f8 100644 --- a/project_rates/solution_evaluation.py +++ b/project_rates/solution_evaluation.py @@ -10,32 +10,28 @@ from partner_programs.solution_team import lock_solution, require_active from project_rates.criteria_policy import revision as criteria_revision, mark_locked from project_rates.models import Criteria +from project_rates.program_experts import can_evaluate_program from project_rates.solution_models import SolutionExpertAssignment, SolutionScore from project_rates.evaluation_math import complete_values, valid_score as valid_value -from users.models import Expert -def program_expert(program, actor, *, required=True): - expert = Expert.objects.filter( - programs=program, user=actor, user__is_active=True - ).first() - if not expert and required: +def require_program_evaluation(program, actor): + if not can_evaluate_program(actor, program.pk): raise SolutionError( "not_program_expert", "Оценивание доступно экспертам этой программы.", status=403, ) - return expert def expert_visible_solutions(program, actor): query = ProgramSolution.objects.filter(program=program, status="submitted") - expert = program_expert(program, actor, required=False) - if not expert: + if not can_evaluate_program(actor, program.pk): return query.none() if program.is_distributed_evaluation: query = query.filter( - expert_assignments__partner_program=program, expert_assignments__expert=expert + expert_assignments__partner_program=program, + expert_assignments__expert__user=actor, ) return query.distinct() @@ -127,7 +123,9 @@ def blocked_reason(program, solution, actor, criteria, state): return "Организатор еще не настроил критерии." if program.is_distributed_evaluation and not state["assigned"]: return "Решение не назначено вам для оценки." - if program.required_evaluators is not None and actor.pk in team_user_ids(solution): + if actor.pk in team_user_ids(solution) and ( + program.required_evaluators is not None or program.is_manager(actor) + ): return "Нельзя оценивать решение своей команды." if ( program.required_evaluators is None @@ -160,7 +158,7 @@ def rating_frame(solution, actor): from partner_programs.solution_api import solution_data, iso program = solution.program - program_expert(program, actor) + require_program_evaluation(program, actor) require_active(solution) if solution.status != "submitted": raise SolutionError("solution_not_submitted", "Сначала нужно сдать решение.") @@ -197,7 +195,7 @@ def rating_frame(solution, actor): def list_ratings(program, actor, query): - program_expert(program, actor) + require_program_evaluation(program, actor) solutions = list( expert_visible_solutions(program, actor).select_related("program").order_by("pk") ) @@ -251,7 +249,7 @@ def score_issue(criterion_id, code, detail): def rate_solution(*, program_id, solution_id, actor, expected_criteria_revision, scores): solution = lock_solution(program_id, solution_id, actor) program = solution.program - program_expert(program, actor) + require_program_evaluation(program, actor) if program.solution_storage_mode != "solution": raise SolutionError( "solution_mode_unavailable", "Программа использует заявки на основе проектов." @@ -278,8 +276,8 @@ def rate_solution(*, program_id, solution_id, actor, expected_criteria_revision, code = "evaluation_closed" elif not criteria: code = "criteria_not_configured" - elif program.required_evaluators is not None and actor.pk in team_user_ids( - solution + elif actor.pk in team_user_ids(solution) and ( + program.required_evaluators is not None or program.is_manager(actor) ): code = "own_team" elif program.is_distributed_evaluation and not state["assigned"]: diff --git a/project_rates/solution_models.py b/project_rates/solution_models.py index e6c77bd3..651b199b 100644 --- a/project_rates/solution_models.py +++ b/project_rates/solution_models.py @@ -144,8 +144,8 @@ def has_scores(self): def clean(self): super().clean() - from users.models import Expert from project_rates.models import ExpertCaseScope + from project_rates.program_experts import program_experts from project_rates.workspace import eligible, target if not self.partner_program_id or not self.solution_id or not self.expert_id: @@ -154,9 +154,7 @@ def clean(self): solution = ProgramSolution.all_objects.filter(pk=self.solution_id).first() if solution and solution.archived_at is not None: raise ValidationError({"solution": "Решение снято с участия."}) - expert = Expert.objects.filter( - pk=self.expert_id, programs__pk=self.partner_program_id, user__is_active=True - ).first() + expert = program_experts(self.partner_program_id).filter(pk=self.expert_id).first() if expert is None: raise ValidationError( {"expert": "Выберите активного эксперта этой программы."} diff --git a/project_rates/tests/test_case_distribution.py b/project_rates/tests/test_case_distribution.py index f69d6428..56257f3d 100644 --- a/project_rates/tests/test_case_distribution.py +++ b/project_rates/tests/test_case_distribution.py @@ -506,6 +506,73 @@ def test_auto_does_not_add_to_project_at_quota(self): self.assertEqual(applied.status_code, 200, applied.data) self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + def test_managers_are_manual_only_even_when_sent_in_auto_case_pools(self): + self.program.managers.add(self.universal) + organizers = {self.manager.expert.pk, self.universal.expert.pk} + self.assertFalse(self.manager.expert.programs.filter(pk=self.program.pk).exists()) + roster = {row["id"]: row for row in self.state()["experts"]} + self.assertTrue(all(roster[pk]["is_organizer"] for pk in organizers)) + self.assertFalse(roster[self.sber.expert.pk]["is_organizer"]) + ids = sorted(organizers | {self.sber.expert.pk}) + command = { + "expert_ids": ids, + "project_ids": [self.projects[0].pk], + "case_pools": [{"case_name": "Сбер", "expert_ids": ids}], + } + preview = self.plan(**command) + self.assertEqual(preview.status_code, 200, preview.data) + self.assertEqual( + [row["expert_id"] for row in preview.data["assignments"]], + [self.sber.expert.pk], + ) + self.assertEqual(preview.data["shortages"][0]["missing"], 1) + self.assertEqual( + self.plan( + **command, + assignment_overrides=[ + {"project_id": self.projects[0].pk, "expert_ids": sorted(organizers)} + ], + ).status_code, + 400, + ) + + manual = self.write(self.projects[:1], [self.manager]) + self.assertEqual(manual.status_code, 200, manual.data) + applied = self.plan(**command) + self.assertEqual(applied.status_code, 200, applied.data) + self.assertEqual(applied.data["shortages"], []) + response = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": applied.data["plan_token"]}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + set( + ProjectExpertAssignment.objects.filter( + project=self.projects[0] + ).values_list("expert_id", flat=True) + ), + {self.manager.expert.pk, self.sber.expert.pk}, + ) + + def test_promoting_an_independent_expert_invalidates_a_signed_plan(self): + preview = self.plan(project_ids=[self.projects[0].pk]) + self.assertEqual(preview.status_code, 200, preview.data) + self.assertFalse(preview.data["shortages"]) + self.program.managers.add(self.universal) + self.assertTrue( + self.universal.expert.programs.filter(pk=self.program.pk).exists() + ) + response = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": preview.data["plan_token"]}, + format="json", + ) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "stale_assignments") + self.assertFalse(ProjectExpertAssignment.objects.exists()) + def test_auto_apply_rechecks_limit_after_restoring_historical_assignments(self): self.program.required_evaluators = None self.program.max_project_rates = 1 diff --git a/project_rates/tests/test_expert_cabinet.py b/project_rates/tests/test_expert_cabinet.py index 7d017588..80f0ffdb 100644 --- a/project_rates/tests/test_expert_cabinet.py +++ b/project_rates/tests/test_expert_cabinet.py @@ -172,7 +172,7 @@ def test_case_is_program_field_and_stale_values_are_not_guessed(self): def test_search_pagination_and_counters_are_not_limited_to_first_page(self): for index in range(25): - self.assign(self.program, f"Project {index:02}") + self.assign(self.program, f"Project {index:02d} title") first, second = self.page(), self.page(offset=20) self.assertEqual(first["limit"], 20) self.assertEqual(len(first["results"]), 20) @@ -182,7 +182,7 @@ def test_search_pagination_and_counters_are_not_limited_to_first_page(self): {r["assignment_id"] for r in first["results"]} & {r["assignment_id"] for r in second["results"]} ) - filtered = self.page(q="Project 24") + filtered = self.page(q="Project 24 title") self.assertEqual(filtered["count"], 1) self.assertEqual(filtered["pending"], 26) diff --git a/project_rates/tests/test_rate_project_api.py b/project_rates/tests/test_rate_project_api.py index 2da94e2e..65a1aa80 100644 --- a/project_rates/tests/test_rate_project_api.py +++ b/project_rates/tests/test_rate_project_api.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient -from project_rates.models import ProjectScore +from project_rates.models import ProjectExpertAssignment, ProjectScore from project_rates.tests.helpers import ( create_rate_criteria, create_rate_expert, @@ -39,6 +39,61 @@ def _rate_url(self, project_id: int | None = None) -> str: def _payload(self, criteria=None, value: str = "8") -> list[dict]: return [{"criterion_id": (criteria or self.criteria).id, "value": value}] + @patch("project_rates.services.send_email.delay") + def test_organizer_can_rate_open_projects_and_loses_only_inherited_access(self, send): + organizer = create_rate_user(prefix="organizer") + self.program.managers.add(organizer) + self.client.force_authenticate(organizer) + self.assertEqual(self.client.get(f"/rate-project/{self.program.pk}").status_code, 200) + self.assertEqual( + self.client.post(self._rate_url(), self._payload(), format="json").status_code, + 201, + ) + self.program.managers.remove(organizer) + self.assertEqual(self.client.get(f"/rate-project/{self.program.pk}").status_code, 403) + self.assertEqual( + self.client.post(self._rate_url(), self._payload(), format="json").status_code, + 403, + ) + self.assertTrue(ProjectScore.objects.filter(user=organizer, value="8").exists()) + organizer.expert.programs.add(self.program) + self.assertEqual(self.client.get(f"/rate-project/{self.program.pk}").status_code, 200) + send.assert_called_once() + + @patch("project_rates.services.send_email.delay") + def test_distributed_organizer_needs_an_explicit_assignment(self, send): + organizer = create_rate_user(prefix="organizer") + self.program.managers.add(organizer) + self.program.program_projects.update(submitted=True) + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + self.client.force_authenticate(organizer) + result = self.client.get(f"/rate-project/{self.program.pk}") + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data["results"], []) + self.assertEqual( + self.client.post(self._rate_url(), self._payload(), format="json").status_code, + 400, + ) + assignment = ProjectExpertAssignment( + partner_program=self.program, project=self.project, expert=organizer.expert + ) + assignment.full_clean() + assignment.save() + self.assertEqual( + self.client.post(self._rate_url(), self._payload(), format="json").status_code, + 201, + ) + send.assert_called_once() + + def test_organizer_cannot_rate_own_team_in_open_mode(self): + self.program.managers.add(self.leader) + self.client.force_authenticate(self.leader) + response = self.client.post(self._rate_url(), self._payload(), format="json") + self.assertEqual(response.status_code, 400) + self.assertIn("Нельзя оценивать проект своей команды.", response.data) + self.assertFalse(ProjectScore.objects.filter(user=self.leader).exists()) + @patch("project_rates.services.send_email.delay") def test_expert_can_rate_project_without_distribution(self, send_email_delay): self.client.force_authenticate(self.expert) @@ -104,7 +159,7 @@ def test_rate_project_rejects_expert_without_program_membership( self.assertEqual(response.status_code, 403) self.assertEqual( response.data["error"], - "you have no permission to rate this program", + "Оценивание доступно экспертам этой программы.", ) self.assertFalse(ProjectScore.objects.filter(user=outsider).exists()) send_email_delay.assert_not_called() diff --git a/project_rates/tests/test_solution_allocation.py b/project_rates/tests/test_solution_allocation.py index 12fde0b4..461a440a 100644 --- a/project_rates/tests/test_solution_allocation.py +++ b/project_rates/tests/test_solution_allocation.py @@ -73,6 +73,71 @@ def test_shortage_never_applies_partial_plan(self): self.assert_error(self.apply(response.data["plan_token"]), "expert_shortage", 409) self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + def test_managers_are_manual_only_even_when_sent_in_auto_case_pools(self): + self.manager.is_active = True + self.manager.save(update_fields=["is_active"]) + before_promotion = self.plan() + self.assertEqual(before_promotion.status_code, 200, before_promotion.data) + self.program.managers.add(self.second) + self.assertTrue(self.second.expert.programs.filter(pk=self.program.pk).exists()) + self.assert_error( + self.apply(before_promotion.data["plan_token"]), "stale_assignments", 409 + ) + + self.assertFalse(self.manager.expert.programs.filter(pk=self.program.pk).exists()) + ids = [self.second.expert.pk, self.manager.expert.pk] + command = dict( + expert_ids=ids, case_pools=[dict(case_name="Задача 1", expert_ids=ids)] + ) + response = self.plan(**command) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["assignments"], []) + self.assertEqual(response.data["shortages"][0]["missing"], 1) + self.assert_error( + self.plan( + **command, + assignment_overrides=[ + dict( + solution_id=self.solution.pk, expert_ids=[self.manager.expert.pk] + ) + ], + ), + "invalid_assignments", + 400, + ) + manual = self.assign([self.solution.pk], actor=self.manager) + self.assertEqual(manual.status_code, 200, manual.data) + response = self.plan(**command) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["shortages"], []) + self.assertEqual(response.data["assignments"], []) + self.assertEqual(self.apply(response.data["plan_token"]).status_code, 200) + self.assertTrue( + SolutionExpertAssignment.objects.filter( + solution=self.solution, expert=self.manager.expert + ).exists() + ) + + def test_completed_manual_assignment_survives_loss_of_organizer_access(self): + self.manager.is_active = True + self.manager.save(update_fields=["is_active"]) + manual = self.assign([self.solution.pk], actor=self.manager) + self.assertEqual(manual.status_code, 200, manual.data) + for criterion, value in zip(self.criteria, ["5", "True", "Готово"]): + SolutionScore.objects.create( + solution=self.solution, user=self.manager, criteria=criterion, value=value + ) + self.program.managers.remove(self.manager) + snapshot = solution_allocation.assignment_snapshot(self.program) + self.assertIn( + dict( + solution_id=self.solution.pk, + expert_id=self.manager.expert.pk, + is_completed=True, + ), + snapshot, + ) + def test_case_pools_and_scopes_constrain_new_assignments(self): scope = ExpertCaseScope.objects.create( partner_program=self.program, diff --git a/project_rates/tests/test_solution_evaluation.py b/project_rates/tests/test_solution_evaluation.py index 7d3b1fdf..204bb002 100644 --- a/project_rates/tests/test_solution_evaluation.py +++ b/project_rates/tests/test_solution_evaluation.py @@ -141,6 +141,20 @@ class SolutionEvaluationTests(EvaluationFixture, TestCase): def setUp(self): self.seed_evaluation() + def test_organizer_can_open_evaluation_and_only_rate_assigned_solutions(self): + self.manager.is_active = True + self.manager.save(update_fields=["is_active"]) + self.assertFalse(self.manager.expert.programs.filter(pk=self.program.pk).exists()) + response = call(self.manager, "get", self.list_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + self.assertEqual(self.rate(self.manager).status_code, 403) + self.program.required_evaluators = 2 + self.program.save(update_fields=["required_evaluators"]) + response = self.assign([self.solution.pk], actor=self.manager) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.rate(self.manager).status_code, 200) + def archive_after_opening_rating(self, *, distributed): from partner_programs.services import solution_archive_actions diff --git a/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py index e66d7e92..17c83b15 100644 --- a/project_rates/tests/test_workspace.py +++ b/project_rates/tests/test_workspace.py @@ -326,12 +326,15 @@ def test_existing_member_accepts_without_role_loss_and_wrong_account_denied(self def test_invitation_lifecycle_and_private_hub(self): invitation, token = self.invitation(self.manager.email) - self.assertEqual( - self.client.get("/evaluation/programs/").data["results"][0]["status"], - "pending", - ) - self.assertFalse( - self.client.get("/evaluation/programs/").data["results"][0].get("assigned") + rows = self.client.get("/evaluation/programs/").data["results"] + joined = next(row for row in rows if row["status"] == "joined") + pending = next(row for row in rows if row["status"] == "pending") + self.assertEqual(joined["assigned"], 0) + self.assertEqual(pending["invitation_id"], invitation.pk) + self.assertNotIn("assigned", pending) + workspace = self.client.get(self.root + "experts/").data + self.assertTrue( + any(row.get("invitation_id") == invitation.pk for row in workspace["experts"]) ) with self.captureOnCommitCallbacks(execute=True): response = self.post( @@ -548,7 +551,7 @@ def test_current_profile_and_program_buttons_use_scoped_roles(self): self.client.get("/auth/users/current/").data["has_evaluation_access"] ) detail = self.client.get(f"/programs/{self.program.pk}/").data - self.assertFalse(detail["is_user_expert"]) + self.assertTrue(detail["is_user_expert"]) self.assertEqual(detail["pending_expert_invitation_id"], invitation.pk) self.post(f"/evaluation/invitations/{token}/accept/", {}) detail = self.client.get(f"/programs/{self.program.pk}/").data diff --git a/project_rates/views.py b/project_rates/views.py index 9f4a8e46..04fb5788 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -1,6 +1,6 @@ from rest_framework import generics, status from rest_framework.response import Response -from rest_framework.exceptions import APIException +from rest_framework.exceptions import APIException, PermissionDenied from rest_framework.exceptions import ValidationError from django_filters import rest_framework as filters @@ -18,7 +18,6 @@ get_rate_program, submit_project_scores, ) -from users.models import Expert from users.permissions import IsExpert, IsExpertPost @@ -34,11 +33,8 @@ def create(self, request, *args, **kwargs) -> Response: data=request.data, ) return Response({"success": True}, status=status.HTTP_201_CREATED) - except Expert.DoesNotExist: - return Response( - {"error": "you have no permission to rate this program"}, - status=status.HTTP_403_FORBIDDEN, - ) + except PermissionDenied as e: + return Response({"error": e.detail}, status=e.status_code) except MaxProjectRatesReached as e: return Response( { diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 591aaea5..6cbde45b 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -25,6 +25,7 @@ ProjectScore, ) from project_rates.allocation_alternatives import alternative_pairs +from project_rates.program_experts import evaluation_programs, program_experts from projects.models import Collaborator from users.models import CustomUser, Expert @@ -52,9 +53,7 @@ def target(program): def expert_rows(program): - return list( - program.experts.filter(user__is_active=True).select_related("user").order_by("pk") - ) + return list(program_experts(program.pk).select_related("user").order_by("pk")) def assignment_snapshot(program): @@ -76,7 +75,11 @@ def revision(program): program.required_evaluators, program.max_project_rates, program.is_distributed_evaluation, - list(program.experts.order_by("pk").values_list("pk", "user__is_active")), + list( + program_experts(program.pk, active_only=False) + .order_by("pk") + .values_list("pk", "user__is_active", "is_organizer") + ), list( program.project_expert_assignments.order_by("pk").values_list( "pk", "project_id", "expert_id" @@ -125,11 +128,13 @@ def revision(program): def check_revision(program, expected): - if expected != revision(program): + actual = revision(program) + if expected != actual: raise WorkspaceConflict( "stale_assignments", "Данные изменились. Обновите список и проверьте изменения.", ) + return actual def check_assignment_limit(program, additions, removals=()): @@ -171,7 +176,9 @@ def enable_workspace(program): .values_list("project_id", "user_id") .distinct() ) - members = dict(program.experts.values_list("user_id", "pk")) + members = dict( + program_experts(program.pk, active_only=False).values_list("user_id", "pk") + ) linked = set( PartnerProgramProject.objects.filter(partner_program=program).values_list( "project_id", flat=True @@ -271,6 +278,7 @@ def workspace_data(program): "email": e.user.email, "avatar": e.user.avatar, "status": "joined", + "is_organizer": e.is_organizer, "assigned": counts[e.pk], "completed": completed[e.pk], "remaining": counts[e.pk] - completed[e.pk], @@ -283,7 +291,16 @@ def workspace_data(program): } for e in members ] - joined_emails = {e.user.email.lower() for e in members} + joined_emails = ( + { + email.lower() + for email in program.experts.filter(user__is_active=True).values_list( + "user__email", flat=True + ) + } + if invitations + else set() + ) for invitation in invitations: if invitation.email in joined_emails: continue @@ -333,6 +350,24 @@ def eligible(scope, case_name): return scope is None or scope.all_cases or case_name in scope.case_names +def validate_project_assignment(project, *, user_id, scope): + if not eligible(scope, project["case_name"]): + raise ValidationError("Эксперт не выбран для кейса этого проекта.") + if user_id in {project["leader_id"], *project["team_user_ids"]}: + raise ValidationError("Эксперт не может оценивать проект своей команды.") + + +def validate_organizer_project_assignments(program, expert, project_ids): + projects = {row["id"]: row for row in distribution_projects(program)} + scope = program.expert_case_scopes.filter(expert=expert).first() + for project_id in project_ids: + project = projects.get(project_id) + if project is None: + raise ValidationError("Выберите сданные проекты этой программы.") + validate_project_assignment(project, user_id=expert.user_id, scope=scope) + check_assignment_limit(program, [(project_id, expert.pk) for project_id in project_ids]) + + def distribution_projects(program, assignments=None): assignments = assignment_snapshot(program) if assignments is None else assignments protected = set( @@ -379,7 +414,7 @@ def distribution_projects(program, assignments=None): 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) + expert = get_object_or_404(program_experts(program.pk), pk=expert_id) scope = validated_case_scope(program, data) ExpertCaseScope.objects.update_or_create( partner_program=program, expert=expert, defaults=scope @@ -392,9 +427,7 @@ def bulk_experts(program_id, user, data): program = managed_program(program_id, user, lock=True) check_revision(program, data["revision"]) expert_ids, invitation_ids = set(data["expert_ids"]), set(data["invitation_ids"]) - experts = list( - program.experts.filter(pk__in=expert_ids, user__is_active=True).order_by("pk") - ) + experts = list(program_experts(program.pk).filter(pk__in=expert_ids).order_by("pk")) invitations = list( program.expert_invitations.select_for_update() .filter(pk__in=invitation_ids) @@ -487,10 +520,9 @@ def save_project_experts(program_id, user, data): if (project_id, expert_id) in existing: continue expert = experts[expert_id] - if not eligible(scopes.get(expert_id), project["case_name"]): - raise ValidationError("Эксперт не выбран для кейса этого проекта.") - if expert.user_id in {project["leader_id"], *project["team_user_ids"]}: - raise ValidationError("Эксперт не может оценивать проект своей команды.") + validate_project_assignment( + project, user_id=expert.user_id, scope=scopes.get(expert_id) + ) additions.append( ProjectExpertAssignment( partner_program=program, project_id=project_id, expert_id=expert_id @@ -634,10 +666,11 @@ def accept_invitation(invitation, user): def my_programs(user): - programs = list( - PartnerProgram.objects.filter(experts__user=user) - .distinct() - .order_by("name", "pk") + programs = list(evaluation_programs(user).order_by("name", "pk")) + expert_id = ( + Expert.objects.filter(user=user).values_list("pk", flat=True).first() + if any(program.solution_storage_mode == "solution" for program in programs) + else None ) rows = [] for program in programs: @@ -646,7 +679,6 @@ def my_programs(user): assignment_snapshot as solution_assignments, ) - expert_id = program.experts.get(user=user).pk assignments = [ row for row in solution_assignments(program, include_historical=False) @@ -667,12 +699,13 @@ def my_programs(user): "remaining": len(assignments) - done, } ) - joined = {p.pk for p in programs} if user.is_active: + independent = PartnerProgram.objects.filter(experts__user=user).values("pk") pending = ( ExpertInvitation.objects.filter( email__iexact=user.email, status="pending", expires_at__gt=timezone.now() ) + .exclude(partner_program_id__in=independent) .select_related("partner_program") .order_by("pk") ) @@ -684,7 +717,6 @@ def my_programs(user): "invitation_id": i.pk, } for i in pending - if i.partner_program_id not in joined ) return {"results": rows, "has_access": bool(rows)} @@ -700,7 +732,7 @@ def own_projects(user_id, project_ids): def assignment_options(program, expert_id): - expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + expert = get_object_or_404(program_experts(program.pk), pk=expert_id) links = list( project_case_links(program, field=get_program_case_field(program)) .select_related("project") @@ -743,7 +775,7 @@ def save_assignments(program_id, user, expert_id, data): program = managed_program(program_id, user, lock=True) check_revision(program, data["revision"]) enable_workspace(program) - expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + expert = get_object_or_404(program_experts(program.pk), pk=expert_id) wanted = set(data["project_ids"]) options = {p["id"]: p for p in assignment_options(program, expert.pk)["projects"]} existing = set( @@ -786,19 +818,25 @@ def save_assignments(program_id, user, expert_id, data): def allocation(program, data): + rev = ( + check_revision(program, data["revision"]) + if "revision" in data + else revision(program) + ) all_experts = expert_rows(program) - experts = all_experts selected = set(data["expert_ids"]) - if (not selected and "case_pools" not in data) or selected - {e.pk for e in experts}: - raise ValidationError("Выберите принявших приглашение экспертов этой программы.") - if "revision" in data: - check_revision(program, data["revision"]) + if (not selected and "case_pools" not in data) or selected - { + e.pk for e in all_experts + }: + raise ValidationError("Выберите активных экспертов этой программы.") + organizers = {e.pk for e in all_experts if e.is_organizer} scopes = {s.expert_id: s for s in program.expert_case_scopes.all()} pools = None if "case_pools" in data: pools = {} for pool in data["case_pools"]: - name, members = pool["case_name"], set(pool["expert_ids"]) + name = pool["case_name"] + members = set(pool["expert_ids"]) - organizers if name in pools or (name is not None and name not in program_cases(program)): raise ValidationError("Проверьте список кейсов распределения.") if members - selected or any( @@ -806,7 +844,7 @@ def allocation(program, data): ): raise ValidationError("Выберите подходящих экспертов для каждого кейса.") pools[name] = members - experts = [e for e in experts if e.pk in selected] + experts = [e for e in all_experts if e.pk in selected and e.pk not in organizers] submitted = list( project_case_links(program, field=get_program_case_field(program)) .filter(submitted=True) @@ -892,7 +930,6 @@ def allocation(program, data): "case_name": link.case_name, } ) - rev = revision(program) if "previous_plan_token" in data: if overrides: raise ValidationError("Обновите предпросмотр после изменения состава.") diff --git a/projects/filters.py b/projects/filters.py index 951733b2..071c9d92 100644 --- a/projects/filters.py +++ b/projects/filters.py @@ -4,7 +4,7 @@ from partner_programs.models import PartnerProgram from project_rates.models import ProjectScore from projects.models import Project -from users.models import Expert +from project_rates.program_experts import can_evaluate_program class ProjectFilter(filters.FilterSet): @@ -69,7 +69,7 @@ def filter_by_partner_program(self, queryset, name, value): return Project.objects.none() program_status = program.projects_availability - user_is_expert = Expert.objects.filter(user=user, programs=program).exists() + user_is_expert = can_evaluate_program(user, program.pk) if program_status != "all_users" and not user_is_expert: return Project.objects.none() diff --git a/users/admin.py b/users/admin.py index ccfae127..d574a038 100644 --- a/users/admin.py +++ b/users/admin.py @@ -217,8 +217,6 @@ def save_model(self, request, obj, form, change): old_user.member.delete() elif old_user.user_type == CustomUser.MENTOR: old_user.mentor.delete() - elif old_user.user_type == CustomUser.EXPERT: - old_user.expert.delete() elif old_user.user_type == CustomUser.INVESTOR: old_user.investor.delete() except Exception: @@ -230,7 +228,7 @@ def save_model(self, request, obj, form, change): elif obj.user_type == CustomUser.MENTOR: Mentor.objects.create(user=old_user) elif obj.user_type == CustomUser.EXPERT: - Expert.objects.create(user=old_user) + Expert.objects.get_or_create(user=old_user) elif obj.user_type == CustomUser.INVESTOR: Investor.objects.create(user=old_user) diff --git a/users/permissions.py b/users/permissions.py index 85c062fe..b77c4b15 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -1,7 +1,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission, SAFE_METHODS -from users.models import Expert +from project_rates.program_experts import can_evaluate_program, has_evaluation_role class IsAchievementOwnerOrReadOnly(BasePermission): @@ -26,8 +26,8 @@ def has_permission(self, request, view): raise PermissionDenied("Authentication credentials were not provided.") program_id = view.kwargs.get("program_id") - if not Expert.objects.filter(programs__id=program_id, user=user).exists(): - raise PermissionDenied("You don't have permission to rate this program") + if not can_evaluate_program(user, program_id): + raise PermissionDenied("Оценивание доступно экспертам этой программы.") return True @@ -40,8 +40,8 @@ def has_permission(self, request, view): user = request.user if not getattr(user, "is_authenticated", False): raise PermissionDenied("Authentication credentials were not provided.") - if not Expert.objects.filter(user=user).exists(): - raise PermissionDenied("User is not an expert") + if not has_evaluation_role(user): + raise PermissionDenied("Оценивание доступно экспертам программы.") return True diff --git a/users/serializers.py b/users/serializers.py index 9f9f0e98..b13f88fb 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -489,12 +489,13 @@ def validate_birthday(self, value): def get_has_evaluation_access(self, user): from project_rates.models import ExpertInvitation + from project_rates.program_experts import evaluation_programs request_user = getattr(self.context.get("request"), "user", None) if not request_user or request_user.pk != user.pk or not user.is_active: return False return ( - Expert.objects.filter(user=user, programs__isnull=False).exists() + evaluation_programs(user).exists() or ExpertInvitation.objects.filter( email__iexact=user.email, status="pending", expires_at__gt=timezone.now() ).exists() @@ -672,15 +673,20 @@ def update(self, instance, validated_data): if value == CustomUser.MEMBER: continue - # delete old user type object and attribute - getattr(instance, user_types_to_attr[instance.user_type]).delete() - setattr(instance, user_types_to_attr[instance.user_type], None) + # Expert identities also hold program assignments and independent access. + if instance.user_type != CustomUser.EXPERT: + old_role = user_types_to_attr[instance.user_type] + getattr(instance, old_role).delete() + setattr(instance, old_role, None) instance.user_type = value - # create new user type object, attribute sets automatically - new_user_type = user_types_to_model[value](user=instance) - new_user_type.save() + role_model = user_types_to_model[value] + if value == CustomUser.EXPERT: + new_role, _ = role_model.objects.get_or_create(user=instance) + else: + new_role = role_model.objects.create(user=instance) + setattr(instance, user_types_to_attr[value], new_role) setattr(instance, attr, value) instance.save()