From d451010c6c1ee971ec2696dc141be55fd70829cc Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 17:03:07 +0300 Subject: [PATCH] feat: expose real expert cabinet assignments --- .../services/assignment_analytics.py | 15 +- project_rates/expert_cabinet.py | 120 +++++++++ project_rates/tests/test_expert_cabinet.py | 253 ++++++++++++++++++ project_rates/workspace_views.py | 19 +- 4 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 project_rates/expert_cabinet.py create mode 100644 project_rates/tests/test_expert_cabinet.py diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py index 7dd429ed..a287a408 100644 --- a/partner_programs/services/assignment_analytics.py +++ b/partner_programs/services/assignment_analytics.py @@ -10,22 +10,23 @@ from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -def annotated_assignment_queryset(program_id): +def annotated_assignment_queryset(program_id=None): """Общая SQL-проверка завершённости реального назначения в программе. Завершённое назначение требует сданного решения и оценок по всем критериям этой программы; отсутствие критериев никогда не означает завершённость. Аннотации пригодны и для списка назначений, и для агрегации работ без N+1. """ + scope = program_id if program_id is not None else OuterRef("partner_program_id") criteria = ( - Criteria.objects.filter(partner_program_id=program_id) + Criteria.objects.filter(partner_program_id=scope) .order_by() .values("partner_program_id") .annotate(total=Count("pk")) ) scores = ( ProjectScore.objects.filter( - criteria__partner_program_id=program_id, + criteria__partner_program_id=scope, project_id=OuterRef("project_id"), user_id=OuterRef("expert__user_id"), ) @@ -34,10 +35,14 @@ def annotated_assignment_queryset(program_id): .annotate(total=Count("criteria_id", distinct=True)) ) link = PartnerProgramProject.objects.filter( - partner_program_id=program_id, project_id=OuterRef("project_id") + partner_program_id=scope, project_id=OuterRef("project_id") ).order_by("pk") return ( - ProjectExpertAssignment.objects.filter(partner_program_id=program_id) + ( + ProjectExpertAssignment.objects.filter(partner_program_id=program_id) + if program_id is not None + else ProjectExpertAssignment.objects.all() + ) .annotate( criteria_total=Coalesce(Subquery(criteria.values("total")[:1]), 0), criteria_scored=Coalesce(Subquery(scores.values("total")[:1]), 0), diff --git a/project_rates/expert_cabinet.py b/project_rates/expert_cabinet.py new file mode 100644 index 00000000..05728535 --- /dev/null +++ b/project_rates/expert_cabinet.py @@ -0,0 +1,120 @@ +"""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.utils import timezone + +from partner_programs.models import 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 projects.models import Collaborator + + +def cabinet_projects(user, query, program_ids): + own = annotated_assignment_queryset().filter( + expert__user=user, + partner_program__experts__user=user, + partner_program_id__in=program_ids, + ) + linked = PartnerProgramProject.objects.filter( + partner_program_id=OuterRef("partner_program_id"), + project_id=OuterRef("project_id"), + ) + visible = own.filter(project__draft=False).filter(Exists(linked)) + pending = visible.filter(is_completed=False).count() + completed = visible.filter(is_completed=True).count() + unavailable = own.count() - pending - completed + selected = visible.filter(is_completed=query["status"] == "completed") + if query.get("program_id"): + selected = selected.filter(partner_program_id=query["program_id"]) + if query.get("q"): + selected = selected.filter(project__name__icontains=query["q"]) + count = selected.count() + scores = ( + ProjectScore.objects.filter( + project_id=OuterRef("project_id"), + criteria__partner_program_id=OuterRef("partner_program_id"), + ) + .order_by() + .values("project_id") + .annotate(total=Count("user_id", distinct=True)) + ) + own_scores = ProjectScore.objects.filter( + project_id=OuterRef("project_id"), + criteria__partner_program_id=OuterRef("partner_program_id"), + user_id=user.pk, + ) + selected = selected.annotate( + cabinet_rated_count=Subquery(scores.values("total")[:1]), + cabinet_has_scores=Exists(own_scores), + cabinet_own_team=Exists( + Collaborator.objects.filter( + project_id=OuterRef("project_id"), user_id=user.pk + ) + ), + ).select_related("partner_program", "project") + offset, limit = query["offset"], query["limit"] + end = offset + limit + assignments = list( + selected.order_by( + F("partner_program__datetime_evaluation_ends").asc(nulls_last=True), + "partner_program_id", + "project__name", + "pk", + )[offset:end] + ) + programs = {a.partner_program_id: a.partner_program for a in assignments} + cases = {} + for program in programs.values(): + cases.update( + { + (program.pk, project_id): name + for project_id, name in project_case_links( + program, field=get_program_case_field(program) + ) + .filter(project_id__in=[a.project_id for a in assignments]) + .values_list("project_id", "case_name") + } + ) + now = timezone.now() + 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 = "Лимит оценок достигнут" + rows.append( + { + "assignment_id": assignment.pk, + "project_id": project.pk, + "program_id": program.pk, + "name": project.name, + "case_name": cases.get((program.pk, project.pk)), + "is_completed": assignment.is_completed, + "can_rate": reason is None, + "blocked_reason": reason, + } + ) + return { + "count": count, + "limit": limit, + "offset": offset, + "pending": pending, + "completed": completed, + "unavailable": unavailable, + "results": rows, + } diff --git a/project_rates/tests/test_expert_cabinet.py b/project_rates/tests/test_expert_cabinet.py new file mode 100644 index 00000000..7d017588 --- /dev/null +++ b/project_rates/tests/test_expert_cabinet.py @@ -0,0 +1,253 @@ +from datetime import timedelta + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) +from project_rates.models import ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) +from projects.models import Collaborator + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class ExpertCabinetTests(TestCase): + def setUp(self): + self.program = create_rate_program( + is_competitive=True, + is_distributed_evaluation=True, + required_evaluators=1, + datetime_evaluation_ends=timezone.now() + timedelta(days=2), + ) + self.user = create_rate_expert(program=self.program) + self.criterion = create_rate_criteria(self.program) + self.project, self.link, self.assignment = self.assign(self.program, "A project") + self.client = APIClient() + self.client.force_authenticate(self.user) + + def assign(self, program, name, **options): + project = create_rate_project(name=name, **options) + program.users.add(project.leader, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(program, project) + link.submitted = True + link.save() + assignment = ProjectExpertAssignment.objects.create( + partner_program=program, project=project, expert=self.user.expert + ) + return project, link, assignment + + def page(self, **query): + response = self.client.get( + "/evaluation/programs/", {"include": "projects", **query} + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "no-store") + return response.data["projects"] + + def complete(self, program, project): + for criterion in program.criterias.all(): + ProjectScore.objects.create( + project=project, + criteria=criterion, + user=self.user, + value={"int": "0", "bool": "False", "str": ""}[criterion.type], + ) + + def test_existing_programs_contract_is_unchanged_without_include(self): + data = self.client.get("/evaluation/programs/").data + self.assertEqual(set(data), {"results", "has_access"}) + self.assertEqual(data["results"][0]["assigned"], 1) + self.assertEqual(data["results"][0]["remaining"], 1) + + def test_only_current_membership_and_own_assignments_are_visible(self): + outsider = create_rate_expert(program=self.program) + other_project = create_rate_project(name="Foreign assignment") + self.program.users.add( + other_project.leader, through_defaults={"partner_program_data": {}} + ) + link_project_to_program(self.program, other_project) + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=other_project, expert=outsider.expert + ) + rows = self.page()["results"] + self.assertEqual([r["project_id"] for r in rows], [self.project.pk]) + self.user.expert.programs.remove(self.program) + self.assertEqual(self.page()["count"], 0) + + def test_foreign_program_filter_does_not_reveal_program_or_projects(self): + foreign = create_rate_program() + data = self.page(program_id=foreign.pk) + self.assertEqual(data["count"], 0) + self.assertEqual(data["results"], []) + self.assertEqual(data["pending"], 1) + self.client.force_authenticate(create_rate_user()) + self.assertEqual(self.page()["results"], []) + self.client.force_authenticate(None) + self.assertEqual( + self.client.get("/evaluation/programs/?include=projects").status_code, 401 + ) + + def test_zero_and_non_numeric_answers_complete_using_existing_rule(self): + create_rate_criteria(self.program, type="bool") + self.complete(self.program, self.project) + self.assertEqual(self.page()["pending"], 0) + done = self.page(status="completed") + self.assertEqual(done["completed"], 1) + self.assertTrue(done["results"][0]["is_completed"]) + self.assertNotIn("in_progress", str(done)) + + def test_programs_sharing_project_do_not_mix_scores_or_completion(self): + other = create_rate_program(datetime_evaluation_ends=None) + self.user.expert.programs.add(other) + create_rate_criteria(other) + other.users.add( + self.project.leader, through_defaults={"partner_program_data": {}} + ) + # Persisted legacy shared applications predate the current team freeze. + PartnerProgramProject.objects.bulk_create( + [ + PartnerProgramProject( + partner_program=other, project=self.project, submitted=True + ) + ] + ) + ProjectExpertAssignment.objects.create( + partner_program=other, project=self.project, expert=self.user.expert + ) + self.complete(other, self.project) + pending = self.page() + self.assertEqual(pending["pending"], 1) + self.assertEqual(pending["completed"], 1) + self.assertEqual(pending["results"][0]["program_id"], self.program.pk) + done = self.page(status="completed") + self.assertEqual(done["results"][0]["program_id"], other.pk) + + def test_completion_does_not_require_numeric_criteria(self): + self.criterion.delete() + create_rate_criteria(self.program, type="bool") + self.complete(self.program, self.project) + self.assertEqual(self.page()["pending"], 0) + self.assertEqual(self.page(status="completed")["completed"], 1) + + def test_draft_and_unlinked_assignments_do_not_disclose_project_data(self): + self.assign(self.program, "Private draft", draft=True) + _, link, _ = self.assign(self.program, "Unlinked project") + # Deliberate historical orphan: normal submitted applications cannot detach here. + PartnerProgramProject.objects.filter(pk=link.pk).delete() + page = self.page() + self.assertEqual(page["count"], 1) + self.assertEqual(page["unavailable"], 2) + self.assertNotIn("Private draft", str(page)) + self.assertNotIn("Unlinked project", str(page)) + + def test_case_is_program_field_and_stale_values_are_not_guessed(self): + field = PartnerProgramField.objects.create( + partner_program=self.program, + name="case", + label="Кейс", + field_type="select", + options="Case A|Case B", + is_required=True, + show_filter=True, + ) + # Historical persisted value; normal writes freeze after submission. + value = PartnerProgramFieldValue( + field=field, program_project=self.link, value_text="Case A" + ) + PartnerProgramFieldValue.objects.bulk_create([value]) + self.assertEqual(self.page()["results"][0]["case_name"], "Case A") + PartnerProgramFieldValue.objects.filter(pk=value.pk).update(value_text="old") + self.assertIsNone(self.page()["results"][0]["case_name"]) + + 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}") + first, second = self.page(), self.page(offset=20) + self.assertEqual(first["limit"], 20) + self.assertEqual(len(first["results"]), 20) + self.assertEqual(len(second["results"]), 6) + self.assertEqual(first["count"], 26) + self.assertFalse( + {r["assignment_id"] for r in first["results"]} + & {r["assignment_id"] for r in second["results"]} + ) + filtered = self.page(q="Project 24") + self.assertEqual(filtered["count"], 1) + self.assertEqual(filtered["pending"], 26) + + def test_deadlines_sort_first_without_deadline_last(self): + for days in (None, 1, 5): + program = create_rate_program( + is_competitive=True, + datetime_evaluation_ends=( + timezone.now() + timedelta(days=days) if days else None + ), + ) + self.user.expert.programs.add(program) + self.assign(program, f"Deadline {days}") + self.assertEqual( + [r["name"].split()[1] for r in self.page()["results"]], + ["1", "project", "5", "None"], + ) + + 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"], "Решение еще не сдано" + ) + PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=True) + Collaborator.objects.bulk_create( + [Collaborator(project=self.project, user=self.user)] + ) + self.assertFalse(self.page()["results"][0]["can_rate"]) + self.program.datetime_evaluation_ends = timezone.now() - timedelta(seconds=1) + self.program.save() + self.assertEqual( + self.page()["results"][0]["blocked_reason"], "Срок оценивания завершен" + ) + self.program.datetime_evaluation_ends = None + self.program.save() + self.program.criterias.all().delete() + self.assertEqual( + self.page()["results"][0]["blocked_reason"], "Критерии не настроены" + ) + + def test_legacy_limit_keeps_existing_raters_eligible(self): + self.program.required_evaluators = None + self.program.max_project_rates = 1 + self.program.save() + other = create_rate_expert(program=self.program) + ProjectScore.objects.create( + project=self.project, criteria=self.criterion, user=other, value="1" + ) + self.assertEqual( + self.page()["results"][0]["blocked_reason"], "Лимит оценок достигнут" + ) + self.complete(self.program, self.project) + self.assertTrue(self.page(status="completed")["results"][0]["can_rate"]) + + def test_invalid_query_is_rejected(self): + for query in ( + {"status": "started"}, + {"limit": 101}, + {"offset": -1}, + {"program_id": 0}, + {"q": "x" * 201}, + ): + with self.subTest(query=query): + response = self.client.get( + "/evaluation/programs/", {"include": "projects", **query} + ) + self.assertEqual(response.status_code, 400) diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index aa7a77b2..45eaad5c 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -255,9 +255,26 @@ def put(self, request, pk): return Response(service.save_quota(pk, request.user, data)) +class CabinetQuery(serializers.Serializer): + status = serializers.ChoiceField(choices=("pending", "completed"), default="pending") + q = serializers.CharField( # noqa: VNE001 + max_length=200, required=False, allow_blank=True + ) + program_id = serializers.IntegerField(min_value=1, required=False) + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + + class MyProgramsView(PrivateView): def get(self, request): - return Response(service.my_programs(request.user)) + data = service.my_programs(request.user) + if request.query_params.get("include") == "projects": + from project_rates.expert_cabinet import cabinet_projects + + query = validated(CabinetQuery, request.query_params) + joined = [row["id"] for row in data["results"] if row["status"] == "joined"] + data["projects"] = cabinet_projects(request.user, query, joined) + return Response(data) class AcceptOwnInvitationView(PrivateView):