diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 870c91d7..07566ce4 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -13,7 +13,7 @@ on: description: "DEV operation" type: choice default: deploy - options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser, verify_program_publication, grant_program_creator_11, dependency_backup, dependency_verify, prepare_pr517_closeout] + options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser, verify_program_publication, grant_program_creator_11, dependency_backup, dependency_verify, prepare_pr517_closeout, prepare_expert_rating] dependency_backup_key: description: "Ключ резервной копии для проверки сохранности данных dev" type: string @@ -420,11 +420,12 @@ jobs: const manager = process.env.VERIFY_MANAGER_ID; const applyFixture = process.env.VERIFY_FIXTURE_APPLY; const phases = { verify_solution_rollback: 'verify_rollback', prepare_solution_browser: 'prepare_browser', finalize_solution_browser: 'finalize_browser', verify_program_publication: 'verify_publication', grant_program_creator_11: 'grant_creator_11', prepare_pr517_closeout: 'prepare_pr517_closeout' }; + phases.prepare_expert_rating = 'prepare_expert_rating'; if (!/^[0-9a-f]{40}$/.test(sha) || !phases[operation] || !/^[a-z0-9-]{8,50}$/.test(key) || !/^[1-9][0-9]{0,8}$/.test(manager)) throw new Error('Invalid acceptance arguments'); if (!['true', 'false'].includes(applyFixture)) throw new Error('Invalid fixture apply flag'); const phase = phases[operation]; const file = phase === 'prepare_pr517_closeout' ? 'scripts/prepare_pr517_closeout.py' : phase === 'grant_creator_11' ? 'scripts/grant_dev_program_creation_20261027.py' : phase === 'verify_publication' ? 'scripts/verify_program_publication_dev.py' : phase === 'verify_rollback' ? 'scripts/verify_program_solutions_dev.py' : 'scripts/prepare_program_solutions_browser.py'; - const source = fs.readFileSync(file, 'utf8'); + const source = fs.readFileSync(phase === 'prepare_expert_rating' ? 'scripts/prepare_expert_rating_dev.py' : file, 'utf8'); const script = [ 'set +x', 'set -eu', diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 98e67cc4..49c06ee4 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -150,7 +150,7 @@ def get_is_user_expert(self, program): return can_evaluate_program(user, program.pk) def get_pending_expert_invitation_id(self, program): - from django.utils import timezone + from project_rates.workspace import pending_expert_invitations user = self.context.get("user") or getattr( self.context.get("request"), "user", None @@ -160,9 +160,8 @@ def get_pending_expert_invitation_id(self, program): 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() - ) + pending_expert_invitations(user.email) + .filter(partner_program=program) .values_list("pk", flat=True) .first() ) diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py index a287a408..45c2e73b 100644 --- a/partner_programs/services/assignment_analytics.py +++ b/partner_programs/services/assignment_analytics.py @@ -2,7 +2,17 @@ from datetime import timedelta -from django.db.models import BooleanField, Case, Count, F, OuterRef, Subquery, Value, When +from django.db.models import ( + BooleanField, + Case, + Count, + F, + OuterRef, + Q, + Subquery, + Value, + When, +) from django.db.models.functions import Coalesce from django.utils import timezone @@ -13,8 +23,8 @@ def annotated_assignment_queryset(program_id=None): """Общая SQL-проверка завершённости реального назначения в программе. - Завершённое назначение требует сданного решения и оценок по всем критериям - этой программы; отсутствие критериев никогда не означает завершённость. + Завершенное назначение требует сданного проекта и оценок по обязательным + критериям программы. Если есть только комментарии, нужна сохраненная оценка. Аннотации пригодны и для списка назначений, и для агрегации работ без N+1. """ scope = program_id if program_id is not None else OuterRef("partner_program_id") @@ -22,7 +32,10 @@ def annotated_assignment_queryset(program_id=None): Criteria.objects.filter(partner_program_id=scope) .order_by() .values("partner_program_id") - .annotate(total=Count("pk")) + .annotate( + total=Count("pk"), + required=Count("pk", filter=~Q(type="str")), + ) ) scores = ( ProjectScore.objects.filter( @@ -32,7 +45,10 @@ def annotated_assignment_queryset(program_id=None): ) .order_by() .values("project_id", "user_id") - .annotate(total=Count("criteria_id", distinct=True)) + .annotate( + total=Count("criteria_id", distinct=True), + required=Count("criteria_id", distinct=True, filter=~Q(criteria__type="str")), + ) ) link = PartnerProgramProject.objects.filter( partner_program_id=scope, project_id=OuterRef("project_id") @@ -46,6 +62,8 @@ def annotated_assignment_queryset(program_id=None): .annotate( criteria_total=Coalesce(Subquery(criteria.values("total")[:1]), 0), criteria_scored=Coalesce(Subquery(scores.values("total")[:1]), 0), + required_criteria=Coalesce(Subquery(criteria.values("required")[:1]), 0), + required_scored=Coalesce(Subquery(scores.values("required")[:1]), 0), project_submitted=Coalesce( Subquery(link.values("submitted")[:1]), Value(False), @@ -56,9 +74,14 @@ def annotated_assignment_queryset(program_id=None): .annotate( is_completed=Case( When( - project_submitted=True, - criteria_total__gt=0, - criteria_scored__gte=F("criteria_total"), + Q(project_submitted=True) + & ( + Q( + required_criteria__gt=0, + required_scored__gte=F("required_criteria"), + ) + | Q(required_criteria=0, criteria_scored__gt=0) + ), then=Value(True), ), default=Value(False), diff --git a/project_rates/expert_cabinet.py b/project_rates/expert_cabinet.py index 201d66ca..4ef64bcb 100644 --- a/project_rates/expert_cabinet.py +++ b/project_rates/expert_cabinet.py @@ -1,23 +1,36 @@ """Read projection of the current expert's actual assignments; no new scoring states.""" -from django.db.models import Count, Exists, F, OuterRef, Subquery +from django.db.models import Count, Exists, F, OuterRef, Q, Subquery from django.utils import timezone -from partner_programs.models import PartnerProgramProject +from partner_programs.models import PartnerProgram, PartnerProgramProject from partner_programs.services.assignment_analytics import annotated_assignment_queryset 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 project_rates.project_scoring import project_scoring_blocker 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_id__in=program_ids, - ).filter( - partner_program_id__in=evaluation_programs(user).values("pk"), +def cabinet_projects( + user, + query, + program_ids, + *, + paginate=True, + as_applications=False, + project_filters: Q | None = None, +): + own = ( + annotated_assignment_queryset() + .filter( + expert__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"), @@ -32,6 +45,8 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications selected = selected.filter(partner_program_id=query["program_id"]) if query.get("q"): selected = selected.filter(project__name__icontains=query["q"]) + if project_filters is not None: + selected = selected.filter(project_filters) count = selected.count() scores = ( ProjectScore.objects.filter( @@ -56,6 +71,11 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications project_id=OuterRef("project_id"), user_id=user.pk ) ), + cabinet_organizer=Exists( + PartnerProgram.managers.through.objects.filter( + partnerprogram_id=OuterRef("partner_program_id"), customuser_id=user.pk + ) + ), ).select_related("partner_program", "project") offset, limit = query["offset"], query["limit"] end = offset + limit @@ -83,22 +103,16 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications rows = [] for assignment in assignments: program, project = assignment.partner_program, assignment.project - reason = None - if program.datetime_evaluation_ends and now > program.datetime_evaluation_ends: - reason = "Срок оценивания завершен" - elif not assignment.criteria_total: - reason = "Критерии не настроены" - elif program.required_evaluators is not None: - if not assignment.project_submitted: - reason = "Решение еще не сдано" - elif project.leader_id == user.pk or assignment.cabinet_own_team: - reason = "Нельзя оценивать проект своей команды" - elif ( - program.max_project_rates - and (assignment.cabinet_rated_count or 0) >= program.max_project_rates - and not assignment.cabinet_has_scores - ): - reason = "Лимит оценок достигнут" + reason = project_scoring_blocker( + program, + now=now, + has_criteria=assignment.criteria_total > 0, + submitted=assignment.project_submitted, + own_team=project.leader_id == user.pk or assignment.cabinet_own_team, + is_organizer=assignment.cabinet_organizer, + rated_count=assignment.cabinet_rated_count or 0, + has_own_scores=assignment.cabinet_has_scores, + ) rows.append( { "assignment_id": assignment.pk, diff --git a/project_rates/project_scoring.py b/project_rates/project_scoring.py new file mode 100644 index 00000000..97a0deea --- /dev/null +++ b/project_rates/project_scoring.py @@ -0,0 +1,29 @@ +def project_scoring_blocker( + program, + *, + now, + has_criteria, + submitted, + own_team, + is_organizer, + rated_count, + has_own_scores, +): + """Explain why an expert cannot submit or change an assessment in the workspace.""" + managed = program.required_evaluators is not None + if program.datetime_evaluation_ends and now > program.datetime_evaluation_ends: + return "Срок оценивания завершен" + if not has_criteria: + return "Критерии не настроены" + if (managed or program.is_competitive) and not submitted: + return "Проект еще не сдан" + if (managed or is_organizer) and own_team: + return "Нельзя оценивать проект своей команды" + if ( + not managed + and program.max_project_rates + and rated_count >= program.max_project_rates + ): + if not has_own_scores: + return "Лимит оценок достигнут" + return None diff --git a/project_rates/serializers.py b/project_rates/serializers.py index d315339b..e07f819f 100644 --- a/project_rates/serializers.py +++ b/project_rates/serializers.py @@ -3,6 +3,7 @@ from core.services import get_views_count from projects.models import Project from .models import Criteria, ProjectScore +from .project_scoring import project_scoring_blocker from .typing import CriteriasResponse, ProjectScoresResponse from .validators import ProjectScoreValidator @@ -114,28 +115,54 @@ def _get_user_scores(self, obj): return [score for score in scores if score.user_id == request.user.id] return [] - def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: + def get_criterias(self, obj) -> list[CriteriasResponse | ProjectScoresResponse]: user_scores = self._get_user_scores(obj) - if self.context.get("managed_evaluation"): - program_id = self.context["view"].kwargs.get("program_id") + criteria = self.context["program_criteria"] + if self.context.get("managed_evaluation") or "assigned_program" in self.context: scores = {score.criteria_id: score for score in user_scores} return [ { **CriteriaSerializer(c).data, **({"value": scores[c.pk].value} if c.pk in scores else {}), } - for c in Criteria.objects.filter(partner_program_id=program_id).order_by( - "pk" - ) + for c in criteria ] if user_scores: serializer = ProjectScoreSerializer(user_scores, many=True) return serializer.data - program_id = self.context["view"].kwargs.get("program_id") - criterias = Criteria.objects.filter(partner_program__id=program_id) - serializer = CriteriaSerializer(criterias, many=True) + serializer = CriteriaSerializer(criteria, many=True) return serializer.data + def to_representation(self, instance): + data = super().to_representation(instance) + program = self.context.get("assigned_program") + if program is None: + return data + + user = self.context["request"].user + reason = project_scoring_blocker( + program, + now=self.context["now"], + has_criteria=bool(self.context["program_criteria"]), + submitted=instance._program_submitted, + own_team=instance.leader_id == user.pk or instance._own_team, + is_organizer=self.context["is_organizer"], + rated_count=instance.rated_count, + has_own_scores=instance._own_score_count > 0, + ) + data.update( + program_link_id=instance._program_link_id, + can_rate=reason is None, + blocked_reason=reason, + ) + if self.context["include_program_fields"]: + from partner_programs.services.project_fields import program_link_fields + + data["program_fields"] = program_link_fields(instance._program_link_id, user)[ + "fields" + ] + return data + def get_scored(self, obj) -> bool: if hasattr(obj, "_own_completed"): return obj._own_completed @@ -157,11 +184,7 @@ def get_rated_experts(self, obj) -> list[int]: if self.context.get("managed_evaluation"): from collections import defaultdict - required = set( - Criteria.objects.filter( - partner_program_id=self.context["view"].kwargs["program_id"] - ).values_list("pk", flat=True) - ) + required = {criterion.pk for criterion in self.context["program_criteria"]} scored = defaultdict(set) for score in program_scores: scored[score.user_id].add(score.criteria_id) diff --git a/project_rates/services.py b/project_rates/services.py index 854c5324..25db11ab 100644 --- a/project_rates/services.py +++ b/project_rates/services.py @@ -8,6 +8,7 @@ Prefetch, Q, QuerySet, + Subquery, Value, When, ) @@ -17,6 +18,7 @@ from partner_programs.models import PartnerProgram, PartnerProgramProject from partner_programs.serializers import ProgramProjectFilterRequestSerializer +from partner_programs.services.assignment_analytics import annotated_assignment_queryset from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Collaborator, Project from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore @@ -78,19 +80,32 @@ def get_projects_for_rate_queryset( to_attr="_program_scores", ) + assigned_only = getattr(program, "assigned_only", False) projects_qs = Project.objects.filter(draft=False, id__in=project_ids) - if program.is_distributed_evaluation or getattr(program, "assigned_only", False): + if program.is_distributed_evaluation or assigned_only: projects_qs = projects_qs.filter( expert_assignments__partner_program=program, expert_assignments__expert__user=user, ) - required_count = Criteria.objects.filter(partner_program=program).count() - submitted = PartnerProgramProject.objects.filter( - partner_program=program, project_id=OuterRef("pk"), submitted=True - ) + links = PartnerProgramProject.objects.filter( + partner_program=program, project_id=OuterRef("pk") + ).order_by("pk") completed = Q(_own_score_count__gt=0) - if program.required_evaluators is not None: + if assigned_only: + completed = Exists( + annotated_assignment_queryset(program.pk).filter( + expert__user=user, project_id=OuterRef("pk"), is_completed=True + ) + ) + projects_qs = projects_qs.annotate( + _program_link_id=Subquery(links.values("pk")[:1]), + _own_team=Exists( + Collaborator.objects.filter(project_id=OuterRef("pk"), user_id=user.pk) + ), + ) + elif program.required_evaluators is not None: + required_count = Criteria.objects.filter(partner_program=program).count() completed = ( Q( _own_score_count__gte=required_count, @@ -107,7 +122,7 @@ def get_projects_for_rate_queryset( filter=Q(scores__criteria__partner_program=program, scores__user=user), distinct=True, ), - _program_submitted=Exists(submitted), + _program_submitted=Exists(links.filter(submitted=True)), rated_count=Count( "scores__user", filter=Q(scores__criteria__partner_program=program), @@ -129,7 +144,8 @@ def get_projects_for_rate_queryset( @transaction.atomic def submit_project_scores(*, user, project_id: int, data) -> None: expected_revision = None - if isinstance(data, dict): + workspace = isinstance(data, dict) + if workspace: expected_revision = data.get("expected_revision") data = data.get("scores") if ( @@ -168,6 +184,14 @@ def submit_project_scores(*, user, project_id: int, data) -> None: user=user, project_id=project_id, data=data, + # The versioned request is also used by legacy callers outside the + # assigned workspace. Only its actual assignments opt into this policy. + workspace=workspace + and ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id=project_id, + expert__user=user, + ).exists(), ) serializer = ProjectScoreCreateSerializer( @@ -203,7 +227,7 @@ def submit_project_scores(*, user, project_id: int, data) -> None: def _prepare_project_score_data( - *, user, project_id: int, data + *, user, project_id: int, data, workspace=False ) -> tuple[list, list, PartnerProgram]: rating_data = [dict(criterion) for criterion in data] criteria_ids = [criterion["criterion_id"] for criterion in rating_data] @@ -221,24 +245,37 @@ def _prepare_project_score_data( program = criteria_qs.first().partner_program is_organizer = program.is_manager(user) - if (program.required_evaluators is not None or is_organizer) and ( + if (workspace or 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: + if ( + program.required_evaluators is not None or (workspace and program.is_competitive) + ) and not ( + PartnerProgramProject.objects.filter( + partner_program=program, project_id=project_id, submitted=True + ).exists() + ): + raise ValidationError("Проект еще не сдан.") + + if program.required_evaluators is not None or workspace: required = set(program.criterias.exclude(type="str").values_list("pk", flat=True)) if not required.issubset(criteria_ids): raise ValidationError( "Заполните все обязательные критерии и сохраните оценку целиком." ) - if not PartnerProgramProject.objects.filter( - partner_program=program, project_id=project_id, submitted=True - ).exists(): - raise ValidationError("Сначала нужно сдать решение.") - # Optional comments are stored too, keeping completion and legacy analytics consistent. + # Persist an empty optional comment only if this expert has no saved answer. present = set(criteria_ids) + present.update( + ProjectScore.objects.filter( + project_id=project_id, + user=user, + criteria__partner_program=program, + criteria__type="str", + ).values_list("criteria_id", flat=True) + ) 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) @@ -274,6 +311,8 @@ def _prepare_project_score_data( def _send_project_rated_email(*, project: Project, program: PartnerProgram) -> None: + if project.leader.email.lower().endswith("@example.invalid"): + return send_email.delay( ProjectRatedParams( message_type=MessageTypeEnum.PROJECT_RATED.value, diff --git a/project_rates/solution_cabinet.py b/project_rates/solution_cabinet.py index 6d51fc58..e74c4137 100644 --- a/project_rates/solution_cabinet.py +++ b/project_rates/solution_cabinet.py @@ -2,9 +2,12 @@ from datetime import datetime, timezone +from django.db.models import Q from django.utils.dateparse import parse_datetime from partner_programs.solution_api import iso +from partner_programs.services.project_filters import get_filterable_program_fields +from partner_programs.utils import filter_program_projects_by_field_name from project_rates.evaluation_math import complete_values from project_rates.expert_cabinet import cabinet_projects from project_rates.models import Criteria @@ -14,12 +17,34 @@ def cabinet_applications(user, query): programs = list(evaluation_programs(user).order_by("pk")) + filters = query.get("filters", {}) + program_id = query.get("program_id") + project_filters = None + if filters: + program = next( + ( + p + for p in programs + if p.pk == program_id and p.solution_storage_mode == "legacy_project" + ), + None, + ) + if program is not None: + fields = get_filterable_program_fields(program).filter( + field_type__in=("select", "radio", "checkbox"), name__in=filters + ) + if set(fields.values_list("name", flat=True)) == filters.keys(): + links = filter_program_projects_by_field_name(program, filters) + project_filters = Q(project_id__in=links.values("project_id")) + else: + project_filters = Q(pk__in=[]) legacy = cabinet_projects( user, query, [p.pk for p in programs if p.solution_storage_mode == "legacy_project"], paginate=False, as_applications=True, + project_filters=project_filters, ) rows = legacy["results"] pending, completed, unavailable = ( @@ -75,9 +100,11 @@ def cabinet_applications(user, query): rows.sort( key=lambda row: ( row["deadline"] is None, - parse_datetime(row["deadline"]) - if row["deadline"] - else datetime.max.replace(tzinfo=timezone.utc), + ( + parse_datetime(row["deadline"]) + if row["deadline"] + else datetime.max.replace(tzinfo=timezone.utc) + ), row["program"]["id"], row["application"]["name"].casefold(), row["assignment_id"], diff --git a/project_rates/solution_cabinet_api.py b/project_rates/solution_cabinet_api.py index 99594526..79853083 100644 --- a/project_rates/solution_cabinet_api.py +++ b/project_rates/solution_cabinet_api.py @@ -1,9 +1,12 @@ +import json + from rest_framework import serializers from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from partner_programs.solution_api import StrictSerializer from partner_programs.solution_errors import MAX_ID +from partner_programs.serializers import ProgramProjectFilterRequestSerializer from project_rates.solution_evaluation_api import EvaluationAPIView from project_rates.solution_cabinet import cabinet_applications @@ -14,9 +17,24 @@ class CabinetQuery(StrictSerializer): max_length=200, allow_blank=True, default="" ) program_id = serializers.IntegerField(min_value=1, max_value=MAX_ID, required=False) + filters = serializers.CharField(max_length=4000, required=False) limit = serializers.IntegerField(min_value=1, max_value=100, default=20) offset = serializers.IntegerField(min_value=0, default=0) + def validate_filters(self, value): + try: + filters = json.loads(value) + except ValueError as exc: + raise serializers.ValidationError("Не удалось прочитать фильтры.") from exc + validator = ProgramProjectFilterRequestSerializer(data={"filters": filters}) + validator.is_valid(raise_exception=True) + return validator.validated_data["filters"] + + def validate(self, attrs): + if attrs.get("filters") and not attrs.get("program_id"): + raise serializers.ValidationError("Выберите программу для фильтрации.") + return attrs + class CabinetApplicationsView(EvaluationAPIView): # The shared cabinet also serves project programs when solutions are disabled. diff --git a/project_rates/tasks.py b/project_rates/tasks.py index de0c6f23..59260013 100644 --- a/project_rates/tasks.py +++ b/project_rates/tasks.py @@ -8,10 +8,9 @@ @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) def send_expert_invitation(invitation_id, token): - from project_rates.models import ExpertInvitation - from project_rates.workspace import digest + from project_rates.workspace import active_expert_invitations, digest - rows = ExpertInvitation.objects.filter( + rows = active_expert_invitations().filter( pk=invitation_id, token_digest=digest(token), status="pending", diff --git a/project_rates/tests/test_expert_cabinet.py b/project_rates/tests/test_expert_cabinet.py index 80f0ffdb..5f435ac5 100644 --- a/project_rates/tests/test_expert_cabinet.py +++ b/project_rates/tests/test_expert_cabinet.py @@ -1,3 +1,4 @@ +import json from datetime import timedelta from django.test import TestCase, override_settings @@ -69,6 +70,38 @@ def test_existing_programs_contract_is_unchanged_without_include(self): self.assertEqual(set(data), {"results", "has_access"}) self.assertEqual(data["results"][0]["assigned"], 1) self.assertEqual(data["results"][0]["remaining"], 1) + self.assertFalse(data["results"][0]["can_export_rates"]) + self.program.managers.add(self.user) + data = self.client.get("/evaluation/programs/").data + self.assertTrue(data["results"][0]["can_export_rates"]) + + def test_program_filter_uses_radio_options_and_keeps_global_counts(self): + field = PartnerProgramField.objects.create( + partner_program=self.program, + name="direction", + label="Направление", + field_type="radio", + options="Город|Образование", + show_filter=True, + ) + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=self.link, field=field, value_text="Город" + ) + ] + ) + for value, count in (("Город", 1), ("Образование", 0)): + response = self.client.get( + "/evaluation/applications/", + { + "program_id": self.program.pk, + "filters": json.dumps({"direction": [value]}), + }, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["count"], count) + self.assertEqual(response.data["pending"], 1) def test_only_current_membership_and_own_assignments_are_visible(self): outsider = create_rate_expert(program=self.program) @@ -172,7 +205,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:02d} title") + self.assign(self.program, f"Project {str(index).zfill(2)} title") first, second = self.page(), self.page(offset=20) self.assertEqual(first["limit"], 20) self.assertEqual(len(first["results"]), 20) @@ -205,7 +238,7 @@ def test_actual_scoring_constraints_are_exposed_without_new_states(self): # Exercise historical unsent assignments without changing the submission policy. PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=False) self.assertEqual( - self.page()["results"][0]["blocked_reason"], "Решение еще не сдано" + self.page()["results"][0]["blocked_reason"], "Проект еще не сдан" ) PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=True) Collaborator.objects.bulk_create( diff --git a/project_rates/tests/test_scoring_workspace.py b/project_rates/tests/test_scoring_workspace.py index 937234a0..0544bfef 100644 --- a/project_rates/tests/test_scoring_workspace.py +++ b/project_rates/tests/test_scoring_workspace.py @@ -159,7 +159,116 @@ def test_non_numeric_only_program_is_completed(self): def test_unsubmitted_link_is_not_completed(self): self.complete(self.project) self.program.program_projects.filter(project=self.project).update(submitted=False) - self.assertEqual(self.page(evaluation_status="pending")["count"], 1) + row = self.page(evaluation_status="pending")["results"][0] + self.assertFalse(row["can_rate"]) + self.assertEqual(row["blocked_reason"], "Проект еще не сдан") + cabinet = self.client.get("/evaluation/applications/").data["results"][0] + self.assertEqual(cabinet["blocked_reason"], row["blocked_reason"]) + + def test_historical_assigned_score_uses_same_completion_and_loads_all_criteria(self): + self.program.required_evaluators = None + self.program.save(update_fields=["required_evaluators"]) + ProjectScore.objects.create( + project=self.project, criteria=self.number, user=self.user, value="0" + ) + row = self.page(evaluation_status="pending")["results"][0] + self.assertFalse(row["scored"]) + self.assertTrue(row["can_rate"]) + self.assertNotIn("program_fields", row) + criteria = {criterion["id"]: criterion for criterion in row["criterias"]} + self.assertEqual(criteria[self.number.pk]["value"], "0") + self.assertNotIn("value", criteria[self.boolean.pk]) + self.assertIn(self.comment.pk, criteria) + self.assertEqual(self.page(evaluation_status="completed")["count"], 0) + cabinet = self.client.get("/evaluation/applications/") + self.assertEqual( + cabinet.data["results"][0]["application"]["project_id"], self.project.pk + ) + + ProjectScore.objects.create( + project=self.project, criteria=self.boolean, user=self.user, value="False" + ) + self.assertEqual(self.page(evaluation_status="completed")["count"], 1) + cabinet = self.client.get("/evaluation/applications/", {"status": "completed"}) + self.assertEqual( + cabinet.data["results"][0]["application"]["project_id"], self.project.pk + ) + + @patch("project_rates.services._send_project_rated_email") + def test_historical_workspace_cannot_save_unsubmitted_project(self, notify): + self.program.required_evaluators = None + self.program.save(update_fields=["required_evaluators"]) + self.program.program_projects.filter(project=self.project).update(submitted=False) + row = self.page(project_id=self.project.pk)["results"][0] + self.assertFalse(row["can_rate"]) + response = self.client.post( + f"/rate-project/rate/{self.project.pk}", + { + "expected_revision": row["criteria_revision"], + "scores": [ + {"criterion_id": self.number.pk, "value": 0}, + {"criterion_id": self.boolean.pk, "value": "False"}, + ], + }, + format="json", + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertIn(row["blocked_reason"], str(response.data)) + self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) + notify.assert_not_called() + + def test_materials_are_read_from_the_assessed_program_link(self): + project = create_rate_project() + other = create_rate_program() + field = PartnerProgramField.objects.create( + partner_program=self.program, + name="details", + label="Подробности", + field_type="text", + ) + assessed_link = PartnerProgramProject( + partner_program=self.program, + project=project, + submitted=True, + submitted_form={ + "fields": [ + { + "id": field.pk, + "name": field.name, + "label": field.label, + "field_type": field.field_type, + "value": "Ответ программы", + } + ], + "sections": {}, + }, + ) + PartnerProgramProject.objects.bulk_create( + [ + PartnerProgramProject( + partner_program=other, project=project, submitted=True + ), + assessed_link, + ] + ) + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=assessed_link, + field=field, + value_text="Текущее значение", + ) + ] + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=project, expert=self.user.expert + ) + row = self.page(project_id=project.pk)["results"][0] + self.assertEqual(row["program_link_id"], assessed_link.pk) + self.assertEqual( + [(item["id"], item["value"]) for item in row["program_fields"]], + [(field.pk, "Ответ программы")], + ) def test_no_criteria_never_means_completed(self): self.program.criterias.all().delete() diff --git a/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py index 17c83b15..47930e2e 100644 --- a/project_rates/tests/test_workspace.py +++ b/project_rates/tests/test_workspace.py @@ -9,6 +9,7 @@ from partner_programs.services.analytics import _solution_rows from project_rates.models import ExpertInvitation, ProjectExpertAssignment, ProjectScore +from project_rates.tasks import send_expert_invitation from project_rates.tests.helpers import ( create_rate_program, create_rate_user, @@ -368,6 +369,58 @@ def test_invitation_lifecycle_and_private_hub(self): self.post(f"/evaluation/invitations/{token}/register/", {}).status_code, 410 ) + @override_settings(PROGRAM_SOLUTIONS_ENABLED=False) + def test_archived_solution_invitation_has_no_cabinet_access_or_actions(self): + invitee = create_rate_user() + archived = create_rate_program(solution_storage_mode="solution") + token = "archived-expert-invitation-0123456789" + invitation = ExpertInvitation.objects.create( + partner_program=archived, + email=invitee.email, + token_digest=digest(token), + expires_at=timezone.now() + timedelta(days=1), + ) + public_url = f"/evaluation/invitations/{token}/" + own_url = f"/evaluation/own-invitations/{invitation.pk}/accept/" + self.client.force_authenticate(None) + self.assertEqual(self.client.get(public_url).status_code, 404) + self.assertEqual(self.post(public_url + "register/", {}).status_code, 404) + self.client.force_authenticate(invitee) + self.assertEqual( + self.client.get("/evaluation/programs/").data, + {"results": [], "has_access": False}, + ) + self.assertFalse( + self.client.get("/auth/users/current/").data["has_evaluation_access"] + ) + self.assertEqual(self.post(public_url + "accept/", {}).status_code, 404) + self.assertEqual(self.post(own_url, {}).status_code, 404) + send_expert_invitation.run(invitation.pk, token) + invitation.refresh_from_db() + self.assertEqual(invitation.status, "pending") + self.assertEqual(invitation.delivery_status, "queued") + self.assertFalse(archived.experts.filter(user=invitee).exists()) + + legacy, _ = self.invitation(invitee.email) + cabinet = self.client.get("/evaluation/programs/").data + self.assertTrue(cabinet["has_access"]) + self.assertEqual( + [row["invitation_id"] for row in cabinet["results"]], [legacy.pk] + ) + self.assertTrue( + self.client.get("/auth/users/current/").data["has_evaluation_access"] + ) + self.assertEqual( + self.client.get(f"/programs/{self.program.pk}/").data[ + "pending_expert_invitation_id" + ], + legacy.pk, + ) + with self.settings(PROGRAM_SOLUTIONS_ENABLED=True): + self.assertEqual(self.client.get(public_url).status_code, 200) + self.assertEqual(self.post(own_url, {}).status_code, 200) + self.assertTrue(archived.experts.filter(user=invitee).exists()) + def test_resending_old_invitation_does_not_overwrite_new_pending_one(self): old, _ = self.invitation("pending@example.com") old.status = "revoked" diff --git a/project_rates/views.py b/project_rates/views.py index 04fb5788..57d341b0 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import ValidationError from django_filters import rest_framework as filters +from django.utils import timezone from projects.filters import ProjectFilter from project_rates.pagination import RateProjectsPagination @@ -105,11 +106,23 @@ def get_serializer_context(self): from project_rates.criteria_policy import revision context = super().get_serializer_context() - context["program_max_rates"] = self._get_program().max_project_rates - context["criteria_revision"] = revision(self._get_program()) - context["managed_evaluation"] = ( - self._get_program().required_evaluators is not None + program = self._get_program() + context.update( + program_max_rates=program.max_project_rates, + criteria_revision=revision(program), + managed_evaluation=program.required_evaluators is not None, + program_criteria=list(program.criterias.order_by("pk")), ) - if context["managed_evaluation"]: - context["program_max_rates"] = self._get_program().required_evaluators + if program.required_evaluators is not None: + context["program_max_rates"] = program.required_evaluators + if program.assigned_only: + context.update( + assigned_program=program, + is_organizer=program.is_manager(self.request.user), + now=timezone.now(), + include_program_fields=( + self.request.method == "GET" + and bool(self.request.query_params.get("project_id")) + ), + ) return context diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 6cbde45b..63191dfb 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -14,9 +14,11 @@ from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from partner_programs.models import PartnerProgram, PartnerProgramProject +from partner_programs.permissions import active_programs from partner_programs.services.assignment_analytics import annotated_assignment_queryset from partner_programs.services.case_analytics import project_case_links from partner_programs.services.case_fields import get_program_case_field +from partner_programs.permissions import can_manage_program from partner_programs.services.management import managed_program, person_name from project_rates.models import ( ExpertCaseScope, @@ -547,14 +549,25 @@ def save_project_experts(program_id, user, data): return workspace_data(program) +def active_expert_invitations(): + programs = active_programs(PartnerProgram.objects.all()) + return ExpertInvitation.objects.filter(partner_program__in=programs) + + +def pending_expert_invitations(email): + return active_expert_invitations().filter( + email__iexact=email, status="pending", expires_at__gt=timezone.now() + ) + + def token_invitation(token, *, lock=False, allow_accepted=False): if not isinstance(token, str) or not 20 <= len(token) <= 100: from django.http import Http404 raise Http404 - qs = ( - ExpertInvitation.objects.select_for_update() if lock else ExpertInvitation.objects - ) + qs = active_expert_invitations() + if lock: + qs = qs.select_for_update(of=("self",)) invitation = get_object_or_404( qs.select_related("partner_program"), token_digest=digest(token) ) @@ -694,6 +707,11 @@ def my_programs(user): { **program_card(program), "status": "joined", + "storage_mode": program.solution_storage_mode, + "can_export_rates": ( + program.solution_storage_mode == "legacy_project" + and can_manage_program(user, program) + ), "assigned": len(assignments), "completed": done, "remaining": len(assignments) - done, @@ -702,9 +720,7 @@ def my_programs(user): 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() - ) + pending_expert_invitations(user.email) .exclude(partner_program_id__in=independent) .select_related("partner_program") .order_by("pk") @@ -895,9 +911,13 @@ def allocation(program, data): candidates.sort( key=lambda e: ( remaining[e.pk], - e.pk - if seed is None - else hashlib.sha256(f"{seed}:{link.project_id}:{e.pk}".encode()).digest(), + ( + e.pk + if seed is None + else hashlib.sha256( + ":".join(map(str, (seed, link.project_id, e.pk))).encode() + ).digest() + ), ) ) candidate_ids[link.project_id] = {e.pk for e in candidates} diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index 45eaad5c..9d403cd1 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -14,7 +14,6 @@ from partner_programs.models import PartnerProgram from partner_programs.services.management import managed_program from project_rates import workspace as service -from project_rates.models import ExpertInvitation from users.models import CustomUser @@ -281,10 +280,15 @@ class AcceptOwnInvitationView(PrivateView): @transaction.atomic def post(self, request, invitation_id): invitation = get_object_or_404( - ExpertInvitation, pk=invitation_id, email__iexact=request.user.email + service.active_expert_invitations(), + pk=invitation_id, + email__iexact=request.user.email, ) PartnerProgram.objects.select_for_update().get(pk=invitation.partner_program_id) - invitation = ExpertInvitation.objects.select_for_update().get(pk=invitation.pk) + invitation = get_object_or_404( + service.active_expert_invitations().select_for_update(), + pk=invitation.pk, + ) service.accept_invitation(invitation, request.user) return Response({"program_id": invitation.partner_program_id, "accepted": True}) diff --git a/scripts/prepare_expert_rating_dev.py b/scripts/prepare_expert_rating_dev.py new file mode 100644 index 00000000..5f6dccb2 --- /dev/null +++ b/scripts/prepare_expert_rating_dev.py @@ -0,0 +1,127 @@ +"""Prepare an isolated project to verify expert scoring on DEV.""" + +import os +import re +import sys +from datetime import timedelta + + +def main(): + sys.path.insert(0, os.getcwd()) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") + import django + + django.setup() + from django.conf import settings + from django.contrib.auth import get_user_model + from django.db import transaction + from django.utils import timezone + from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, + ) + from partner_programs.services.project_fields import submit_program_project + from project_rates.models import Criteria, ProjectExpertAssignment + from projects.models import Project + from users.models import Expert + + if ( + settings.EXPERT_WORKSPACE_FRONTEND_URL != "https://dev.procollab.ru" + or settings.PROGRAM_SOLUTIONS_ENABLED + or os.environ["VERIFY_PHASE"] != "prepare_expert_rating" + or os.environ["VERIFY_FIXTURE_APPLY"] != "true" + ): + raise ValueError("This fixture requires the DEV project environment.") + key = os.environ["VERIFY_FIXTURE_KEY"] + if not re.fullmatch(r"[a-z0-9-]{8,28}", key): + raise ValueError("Invalid expert rating fixture key.") + expert = Expert.objects.get( + user_id=int(os.environ["VERIFY_MANAGER_ID"]), user__is_active=True + ) + tag = "qa-expert-" + key + with transaction.atomic(): + if PartnerProgram.objects.filter(tag=tag).exists(): + raise ValueError("Fixture already exists.") + leader = get_user_model().objects.create_user( + email=f"{tag}@example.invalid", + first_name="Приемка", + last_name="Оценивание", + birthday="1990-01-01", + is_active=True, + ) + now = timezone.now() + program = PartnerProgram.objects.create( + name=f"[QA DEV] Оценка проекта {key}", + tag=tag, + description="Изолированная проверка пути эксперта.", + city="Москва", + draft=False, + program_format="case_championship", + solution_storage_mode="legacy_project", + is_competitive=True, + is_distributed_evaluation=True, + required_evaluators=1, + legacy_team_min_size=1, + datetime_started=now - timedelta(days=1), + datetime_registration_ends=now - timedelta(hours=1), + datetime_project_submission_ends=now + timedelta(days=1), + datetime_evaluation_ends=now + timedelta(days=2), + datetime_finished=now + timedelta(days=3), + ) + expert.programs.add(program) + project = Project.objects.create( + leader=leader, + name="Проверка оценки DEV", + description="Описание тестового проекта.", + draft=True, + is_public=False, + ) + PartnerProgramUserProfile.objects.create( + partner_program=program, user=leader, project=project, partner_program_data={} + ) + link = PartnerProgramProject.objects.create( + partner_program=program, project=project + ) + field = PartnerProgramField.objects.create( + partner_program=program, + name="track", + label="Направление", + field_type="radio", + options="Город|Образование", + is_required=True, + show_filter=True, + ) + PartnerProgramFieldValue.objects.create( + program_project=link, field=field, value_text="Город" + ) + case = PartnerProgramField.objects.create( + partner_program=program, + name="case", + label="Кейс", + field_type="select", + purpose="case", + options="Город", + is_required=True, + show_filter=True, + ) + PartnerProgramFieldValue.objects.create( + program_project=link, field=case, value_text="Город" + ) + Criteria.objects.create( + partner_program=program, name="Баллы", type="int", min_value=0, max_value=10 + ) + Criteria.objects.create( + partner_program=program, name="Критерий выполнен", type="bool" + ) + submit_program_project(link.pk, leader) + ProjectExpertAssignment.objects.create( + partner_program=program, project=project, expert=expert + ) + sys.stdout.write(f"DEV_EXPERT_RATING_PROGRAM={program.pk} PROJECT={project.pk}\n") + + +if __name__ == "__main__": + main() diff --git a/users/serializers.py b/users/serializers.py index b13f88fb..968531cf 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -488,17 +488,15 @@ def validate_birthday(self, value): has_evaluation_access = serializers.SerializerMethodField() def get_has_evaluation_access(self, user): - from project_rates.models import ExpertInvitation from project_rates.program_experts import evaluation_programs + from project_rates.workspace import pending_expert_invitations 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 ( evaluation_programs(user).exists() - or ExpertInvitation.objects.filter( - email__iexact=user.email, status="pending", expires_at__gt=timezone.now() - ).exists() + or pending_expert_invitations(user.email).exists() ) verification_notice_acknowledged_at = serializers.DateTimeField(read_only=True)