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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions project_rates/migrations/0008_projectscore_value_1000.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [("project_rates", "0007_expertinvitation_case_scope")]

operations = [
migrations.AlterField(
model_name="projectscore",
name="value",
field=models.CharField(
verbose_name="Значение", blank=True, max_length=1000, null=True
),
),
]
2 changes: 1 addition & 1 deletion project_rates/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class ProjectScore(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="scores")

value = models.CharField(
verbose_name="Значение", max_length=50, null=True, blank=True
verbose_name="Значение", max_length=1000, null=True, blank=True
)

def __str__(self):
Expand Down
2 changes: 2 additions & 0 deletions project_rates/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse:
return serializer.data

def get_scored(self, obj) -> bool:
if hasattr(obj, "_own_completed"):
return obj._own_completed
user_scores = self._get_user_scores(obj)
if self.context.get("managed_evaluation"):
program_id = self.context["view"].kwargs.get("program_id")
Expand Down
41 changes: 40 additions & 1 deletion project_rates/services.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from django.db import transaction
from django.db.models import Count, Prefetch, Q, QuerySet
from django.db.models import (
BooleanField,
Case,
Count,
Exists,
OuterRef,
Prefetch,
Q,
QuerySet,
Value,
When,
)
from django.utils import timezone

from rest_framework.exceptions import ValidationError
Expand Down Expand Up @@ -74,12 +85,40 @@ def get_projects_for_rate_queryset(
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
)
completed = Q(_own_score_count__gt=0)
if program.required_evaluators is not None:
completed = (
Q(
_own_score_count__gte=required_count,
_program_submitted=True,
)
if required_count
else Q(pk__isnull=True)
)

return (
projects_qs.annotate(
_own_score_count=Count(
"scores__criteria",
filter=Q(scores__criteria__partner_program=program, scores__user=user),
distinct=True,
),
_program_submitted=Exists(submitted),
rated_count=Count(
"scores__user",
filter=Q(scores__criteria__partner_program=program),
distinct=True,
),
)
.annotate(
_own_completed=Case(
When(completed, then=Value(True)),
default=Value(False),
output_field=BooleanField(),
)
)
.prefetch_related(scores_prefetch)
Expand Down
223 changes: 223 additions & 0 deletions project_rates/tests/test_scoring_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
from datetime import timedelta
from unittest.mock import patch

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.criteria_policy import revision
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,
)


@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"])
class ScoringWorkspaceTests(TestCase):
def setUp(self):
self.program = create_rate_program(
is_competitive=True,
is_distributed_evaluation=True,
required_evaluators=2,
datetime_evaluation_ends=timezone.now() + timedelta(days=2),
)
self.user = create_rate_expert(program=self.program)
self.number = create_rate_criteria(self.program, min_value=0, max_value=10)
self.boolean = create_rate_criteria(self.program, type="bool")
self.comment = self.program.criterias.get(type="str")
self.project = self.assign("Main")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.url = f"/rate-project/{self.program.pk}"

def assign(self, name):
project = create_rate_project(name=name)
self.program.users.add(
project.leader, through_defaults={"partner_program_data": {}}
)
link = link_project_to_program(self.program, project)
link.submitted = True
link.save()
ProjectExpertAssignment.objects.create(
partner_program=self.program,
project=project,
expert=self.user.expert,
)
return project

def complete(self, project, user=None):
for criterion, value in [
(self.number, "0"),
(self.boolean, "False"),
(self.comment, ""),
]:
ProjectScore.objects.create(
project=project, criteria=criterion, user=user or self.user, value=value
)

def page(self, **query):
response = self.client.get(self.url, {"assigned_only": 1, **query})
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response["Cache-Control"], "no-store")
return response.data

def test_own_status_ignores_another_experts_scores(self):
self.complete(self.project, create_rate_expert(program=self.program))
data = self.page(evaluation_status="pending")
self.assertEqual(data["evaluation_counts"], {"pending": 1, "completed": 0})
self.assertFalse(data["results"][0]["scored"])
self.assertEqual(self.page(evaluation_status="completed")["results"], [])

def test_status_ignores_scores_from_another_program_on_same_project(self):
other = create_rate_program()
# Historical shared-project link, before team-freeze rules were introduced.
PartnerProgramProject.objects.bulk_create(
[
PartnerProgramProject(
partner_program=other, project=self.project, submitted=True
)
]
)
criterion = create_rate_criteria(other)
ProjectScore.objects.create(
project=self.project, user=self.user, criteria=criterion, value="8"
)
self.assertEqual(self.page(evaluation_status="pending")["count"], 1)

def test_counts_are_before_status_and_pagination_but_after_search(self):
self.complete(self.project)
self.assign("Second")
data = self.page(evaluation_status="completed", limit=1)
self.assertEqual(data["evaluation_counts"], {"pending": 1, "completed": 1})
self.assertTrue(data["results"][0]["scored"])
filtered = self.page(name__contains="Main", evaluation_status="pending")
self.assertEqual(filtered["count"], 0)
self.assertEqual(filtered["evaluation_counts"], {"pending": 0, "completed": 1})

def test_configured_post_filters_scope_both_counts(self):
other = self.assign("Other")
self.complete(self.project)
field = PartnerProgramField.objects.create(
partner_program=self.program,
name="case",
label="Кейс",
field_type="select",
options="A|B",
show_filter=True,
is_required=True,
)
# Fixture values predate submission; current editing rules freeze them afterwards.
PartnerProgramFieldValue.objects.bulk_create(
[
PartnerProgramFieldValue(
program_project=self.program.program_projects.get(
project=self.project
),
field=field,
value_text="A",
),
PartnerProgramFieldValue(
program_project=self.program.program_projects.get(project=other),
field=field,
value_text="B",
),
]
)
response = self.client.post(
self.url + "?assigned_only=1&evaluation_status=completed",
{"filters": {"case": ["B"]}},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(
response.data["evaluation_counts"], {"pending": 1, "completed": 0}
)
self.assertEqual(response.data["results"], [])

def test_zero_false_and_optional_empty_comment_are_completed(self):
self.complete(self.project)
self.assertEqual(self.page(evaluation_status="completed")["count"], 1)

def test_non_numeric_only_program_is_completed(self):
self.number.delete()
for criterion, value in [(self.boolean, "False"), (self.comment, "")]:
ProjectScore.objects.create(
project=self.project, criteria=criterion, user=self.user, value=value
)
self.assertEqual(self.page(evaluation_status="completed")["count"], 1)

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)

def test_no_criteria_never_means_completed(self):
self.program.criterias.all().delete()
self.assertEqual(self.page(evaluation_status="completed")["count"], 0)

def test_unassigned_foreign_project_is_not_revealed(self):
project = create_rate_project()
self.program.users.add(
project.leader, through_defaults={"partner_program_data": {}}
)
link_project_to_program(self.program, project)
self.assertEqual(self.page(project_id=project.pk)["results"], [])

def test_legacy_any_expert_filter_keeps_original_meaning(self):
self.complete(self.project, create_rate_expert(program=self.program))
data = self.page(is_rated_by_expert="true", evaluation_status="pending")
self.assertEqual(data["count"], 1)

def test_invalid_status_and_missing_permissions(self):
self.assertEqual(
self.client.get(self.url, {"evaluation_status": "draft"}).status_code, 400
)
self.client.force_authenticate(create_rate_user())
self.assertEqual(self.client.get(self.url).status_code, 403)
self.client.force_authenticate(None)
self.assertIn(self.client.get(self.url).status_code, [401, 403])

@patch("project_rates.services._send_project_rated_email")
def test_long_comment_is_saved_atomically_and_overflow_does_not_change_scores(
self, _email
):
def payload(comment, points=0):
self.program.refresh_from_db()
return {
"expected_revision": revision(self.program),
"scores": [
{"criterion_id": self.number.pk, "value": points},
{"criterion_id": self.boolean.pk, "value": "False"},
{"criterion_id": self.comment.pk, "value": comment},
],
}

response = self.client.post(
f"/rate-project/rate/{self.project.pk}", payload("я" * 1000), format="json"
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(
ProjectScore.objects.get(criteria=self.comment, user=self.user).value,
"я" * 1000,
)
response = self.client.post(
f"/rate-project/rate/{self.project.pk}", payload("я" * 1001, 5), format="json"
)
self.assertEqual(response.status_code, 400, response.data)
self.assertEqual(
ProjectScore.objects.get(criteria=self.number, user=self.user).value, "0"
)
self.assertEqual(
ProjectScore.objects.get(criteria=self.comment, user=self.user).value,
"я" * 1000,
)
22 changes: 22 additions & 0 deletions project_rates/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from rest_framework.exceptions import ValidationError

from django_filters import rest_framework as filters

Expand Down Expand Up @@ -61,6 +62,27 @@ class ProjectListForRate(generics.ListAPIView):
filterset_class = ProjectFilter
pagination_class = RateProjectsPagination

def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
# Counts describe the filtered list, before own-status and pagination.
completed = queryset.filter(_own_completed=True).count()
self.evaluation_counts = {
"pending": queryset.count() - completed,
"completed": completed,
}
selected = self.request.query_params.get("evaluation_status")
if selected not in (None, "pending", "completed"):
raise ValidationError({"evaluation_status": "Выберите статус оценки."})
if selected:
queryset = queryset.filter(_own_completed=selected == "completed")
return queryset.order_by("pk")

def get_paginated_response(self, data):
response = super().get_paginated_response(data)
response.data["evaluation_counts"] = self.evaluation_counts
response["Cache-Control"] = "no-store"
return response

def post(self, request, *args, **kwargs):
"""Allow POST with filters in JSON body."""
return self.list(request, *args, **kwargs)
Expand Down
Loading