Skip to content

Commit 33bd64d

Browse files
authored
Merge pull request #579 from PROCOLLAB-github/fix/project-evaluation-logic
Лимит оценок и фильтры проектов для оценки
2 parents 807ea2d + 036d920 commit 33bd64d

6 files changed

Lines changed: 182 additions & 35 deletions

File tree

partner_programs/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class Meta:
8080
"city",
8181
"is_competitive",
8282
"projects_availability",
83+
"max_project_rates",
8384
"draft",
8485
(
8586
"datetime_started",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.11 on 2025-12-03 08:01
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("partner_programs", "0012_partnerprogram_registration_link"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="partnerprogram",
15+
name="max_project_rates",
16+
field=models.PositiveIntegerField(
17+
blank=True,
18+
default=1,
19+
help_text="Ограничение на число экспертов, которые могут оценить один проект в программе",
20+
null=True,
21+
verbose_name="Максимальное количество оценок проектов",
22+
),
23+
),
24+
]

partner_programs/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ class PartnerProgram(models.Model):
7979
verbose_name="Ссылка на регистрацию",
8080
help_text="Адрес страницы регистрации (например, на Tilda)",
8181
)
82+
max_project_rates = models.PositiveIntegerField(
83+
null=True,
84+
blank=True,
85+
default=1,
86+
verbose_name="Максимальное количество оценок проектов",
87+
help_text="Ограничение на число экспертов, которые могут оценить один проект в программе",
88+
)
8289
data_schema = models.JSONField(
8390
verbose_name="Схема данных в формате JSON",
8491
help_text="Ключи - имена полей, значения - тип поля ввода",

project_rates/serializers.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ class ProjectListForRateSerializer(serializers.ModelSerializer):
6161
views_count = serializers.SerializerMethodField()
6262
criterias = serializers.SerializerMethodField()
6363
scored = serializers.SerializerMethodField()
64-
scored_expert_id = serializers.IntegerField(read_only=True, allow_null=True)
64+
rated_experts = serializers.SerializerMethodField()
65+
rated_count = serializers.SerializerMethodField()
66+
max_rates = serializers.SerializerMethodField()
6567

6668
class Meta:
6769
model = Project
@@ -76,22 +78,47 @@ class Meta:
7678
"region",
7779
"views_count",
7880
"scored",
79-
"scored_expert_id",
81+
"rated_experts",
82+
"rated_count",
83+
"max_rates",
8084
"criterias",
8185
]
8286

8387
def get_views_count(self, obj) -> int:
8488
return get_views_count(obj)
8589

90+
def _get_program_scores(self, obj):
91+
if hasattr(obj, "_program_scores"):
92+
return obj._program_scores
93+
program_id = self.context["view"].kwargs.get("program_id")
94+
return ProjectScore.objects.filter(
95+
project=obj, criteria__partner_program_id=program_id
96+
).select_related("criteria", "user")
97+
8698
def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse:
8799
program_id = self.context["view"].kwargs.get("program_id")
88-
if obj.scored:
89-
scores = ProjectScore.objects.filter(project=obj).select_related("criteria")
90-
serializer = ProjectScoreSerializer(scores, many=True)
100+
program_scores = self._get_program_scores(obj)
101+
if program_scores:
102+
serializer = ProjectScoreSerializer(program_scores, many=True)
91103
else:
92104
cirterias = Criteria.objects.filter(partner_program__id=program_id)
93105
serializer = CriteriaSerializer(cirterias, many=True)
94106
return serializer.data
95107

96108
def get_scored(self, obj) -> bool:
97-
return bool(obj.scored)
109+
program_scores = self._get_program_scores(obj)
110+
return bool(program_scores)
111+
112+
def get_rated_experts(self, obj) -> list[int]:
113+
program_scores = self._get_program_scores(obj)
114+
return list({score.user_id for score in program_scores})
115+
116+
def get_rated_count(self, obj) -> int:
117+
rated_attr = getattr(obj, "rated_count", None)
118+
if rated_attr is not None:
119+
return rated_attr
120+
program_scores = self._get_program_scores(obj)
121+
return len({score.user_id for score in program_scores})
122+
123+
def get_max_rates(self, obj):
124+
return self.context.get("program_max_rates")

project_rates/views.py

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from django.contrib.auth import get_user_model
2-
from django.db.models import QuerySet, Count, OuterRef, Subquery, IntegerField
2+
from django.db import transaction
3+
from django.db.models import Count, Prefetch, Q, QuerySet
34

45
from rest_framework import generics, status
6+
from rest_framework.exceptions import ValidationError
57
from rest_framework.response import Response
68

79
from django_filters import rest_framework as filters
810

9-
from partner_programs.models import PartnerProgramUserProfile
11+
from partner_programs.models import PartnerProgram, PartnerProgramProject
12+
from partner_programs.serializers import ProgramProjectFilterRequestSerializer
13+
from partner_programs.utils import filter_program_projects_by_field_name
1014
from projects.models import Project
1115
from projects.filters import ProjectFilter
12-
from project_rates.models import ProjectScore
16+
from project_rates.models import Criteria, ProjectScore
1317
from project_rates.pagination import RateProjectsPagination
1418
from project_rates.serializers import (
1519
ProjectScoreCreateSerializer,
@@ -27,7 +31,7 @@ class RateProject(generics.CreateAPIView):
2731
serializer_class = ProjectScoreCreateSerializer
2832
permission_classes = [IsExpertPost]
2933

30-
def get_needed_data(self) -> tuple[dict, list[int], str]:
34+
def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]:
3135
data = self.request.data
3236
user_id = self.request.user.id
3337
project_id = self.kwargs.get("project_id")
@@ -36,34 +40,68 @@ def get_needed_data(self) -> tuple[dict, list[int], str]:
3640
criterion["criterion_id"] for criterion in data
3741
] # is needed for validation later
3842

39-
expert = Expert.objects.get(
40-
user__id=user_id, programs__criterias__id=criteria_to_get[0]
43+
criteria_qs = Criteria.objects.filter(id__in=criteria_to_get).select_related(
44+
"partner_program"
4145
)
46+
partner_program_ids = (
47+
criteria_qs.values_list("partner_program_id", flat=True).distinct()
48+
)
49+
if not criteria_qs.exists():
50+
raise ValueError("Criteria not found")
51+
if partner_program_ids.count() != 1:
52+
raise ValueError("All criteria must belong to the same program")
53+
program = criteria_qs.first().partner_program
54+
55+
Expert.objects.get(user__id=user_id, programs=program)
4256

4357
for criterion in data:
4458
criterion["user"] = user_id
4559
criterion["project"] = project_id
4660
criterion["criteria"] = criterion.pop("criterion_id")
4761

48-
return data, criteria_to_get, expert.programs.all().first().name
62+
if not PartnerProgramProject.objects.filter(
63+
partner_program=program, project_id=project_id
64+
).exists():
65+
raise ValueError("Project is not linked to the program")
66+
67+
return data, criteria_to_get, program
4968

5069
def create(self, request, *args, **kwargs) -> Response:
5170
try:
52-
data, criteria_to_get, program_name = self.get_needed_data()
71+
data, criteria_to_get, program = self.get_needed_data()
72+
project_id = data[0]["project"]
73+
user_id = request.user.id
5374

5475
serializer = ProjectScoreCreateSerializer(
5576
data=data, criteria_to_get=criteria_to_get, many=True
5677
)
5778
serializer.is_valid(raise_exception=True)
5879

59-
ProjectScore.objects.bulk_create(
60-
[ProjectScore(**item) for item in serializer.validated_data],
61-
update_conflicts=True,
62-
update_fields=["value"],
63-
unique_fields=["criteria", "user", "project"],
80+
scores_qs = ProjectScore.objects.filter(
81+
project_id=project_id, criteria__partner_program=program
6482
)
83+
user_has_scores = scores_qs.filter(user_id=user_id).exists()
84+
85+
if program.max_project_rates:
86+
distinct_raters = scores_qs.values("user_id").distinct().count()
87+
if not user_has_scores and distinct_raters >= program.max_project_rates:
88+
return Response(
89+
{
90+
"error": "max project rates reached for this program",
91+
"max_project_rates": program.max_project_rates,
92+
},
93+
status=status.HTTP_400_BAD_REQUEST,
94+
)
95+
96+
with transaction.atomic():
97+
ProjectScore.objects.bulk_create(
98+
[ProjectScore(**item) for item in serializer.validated_data],
99+
update_conflicts=True,
100+
update_fields=["value"],
101+
unique_fields=["criteria", "user", "project"],
102+
)
65103

66-
project = Project.objects.select_related("leader").get(id=data[0]["project"])
104+
project = Project.objects.select_related("leader").get(id=project_id)
67105

68106
send_email.delay(
69107
ProjectRatedParams(
@@ -72,7 +110,7 @@ def create(self, request, *args, **kwargs) -> Response:
72110
project_name=project.name,
73111
project_id=project.id,
74112
schema_id=2,
75-
program_name=program_name,
113+
program_name=program.name,
76114
)
77115
)
78116

@@ -82,6 +120,8 @@ def create(self, request, *args, **kwargs) -> Response:
82120
{"error": "you have no permission to rate this program"},
83121
status=status.HTTP_403_FORBIDDEN,
84122
)
123+
except ValueError as e:
124+
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
85125
except Exception as e:
86126
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
87127

@@ -93,18 +133,59 @@ class ProjectListForRate(generics.ListAPIView):
93133
filterset_class = ProjectFilter
94134
pagination_class = RateProjectsPagination
95135

136+
def _get_program(self) -> PartnerProgram:
137+
return PartnerProgram.objects.get(pk=self.kwargs.get("program_id"))
138+
139+
def _get_filters(self) -> dict:
140+
"""
141+
Accept filters from JSON body to mirror /partner_programs/<id>/projects/filter/:
142+
{"filters": {"case": ["Кейс 1"]}}
143+
"""
144+
data = getattr(self.request, "data", None)
145+
body_filters = (
146+
data.get("filters") if isinstance(data, dict) and data.get("filters") else {}
147+
)
148+
return body_filters if isinstance(body_filters, dict) else {}
149+
96150
def get_queryset(self) -> QuerySet[Project]:
97-
projects_ids = PartnerProgramUserProfile.objects.filter(
98-
project__isnull=False, partner_program__id=self.kwargs.get("program_id")
99-
).values_list("project__id", flat=True)
100-
# `Count` the easiest way to check for rate exist (0 -> does not exist).
101-
scored_expert_subquery = ProjectScore.objects.filter(
102-
project=OuterRef("pk")
103-
).values("user_id")[:1]
104-
105-
return Project.objects.filter(draft=False, id__in=projects_ids).annotate(
106-
scored=Count("scores"),
107-
scored_expert_id=Subquery(
108-
scored_expert_subquery, output_field=IntegerField()
109-
),
151+
program = self._get_program()
152+
153+
filters_serializer = ProgramProjectFilterRequestSerializer(
154+
data={"filters": self._get_filters()}
110155
)
156+
filters_serializer.is_valid(raise_exception=True)
157+
field_filters = filters_serializer.validated_data.get("filters", {})
158+
159+
try:
160+
program_projects_qs = filter_program_projects_by_field_name(
161+
program, field_filters
162+
)
163+
except ValueError as e:
164+
raise ValidationError({"filters": str(e)})
165+
166+
project_ids = program_projects_qs.values_list("project_id", flat=True)
167+
168+
scores_prefetch = Prefetch(
169+
"scores",
170+
queryset=ProjectScore.objects.filter(
171+
criteria__partner_program=program
172+
).select_related("user"),
173+
to_attr="_program_scores",
174+
)
175+
176+
return (
177+
Project.objects.filter(draft=False, id__in=project_ids)
178+
.annotate(
179+
rated_count=Count(
180+
"scores__user",
181+
filter=Q(scores__criteria__partner_program=program),
182+
distinct=True,
183+
)
184+
)
185+
.prefetch_related(scores_prefetch)
186+
)
187+
188+
def get_serializer_context(self):
189+
context = super().get_serializer_context()
190+
context["program_max_rates"] = self._get_program().max_project_rates
191+
return context

users/permissions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class IsExpert(BasePermission):
2222

2323
def has_permission(self, request, view):
2424
user = request.user
25+
if not getattr(user, "is_authenticated", False):
26+
raise PermissionDenied("Authentication credentials were not provided.")
2527
program_id = view.kwargs.get("program_id")
2628

2729
if not user.user_type == 3:
@@ -37,7 +39,12 @@ class IsExpertPost(BasePermission):
3739
"""
3840

3941
def has_permission(self, request, view):
40-
return True if request.user.user_type == 3 else False
42+
user = request.user
43+
if not getattr(user, "is_authenticated", False):
44+
raise PermissionDenied("Authentication credentials were not provided.")
45+
if getattr(user, "user_type", None) != 3:
46+
raise PermissionDenied("User is not an expert")
47+
return True
4148

4249

4350
class CustomIsAuthenticated(BasePermission):

0 commit comments

Comments
 (0)