Skip to content

Commit e61998a

Browse files
authored
Merge pull request #583 from PROCOLLAB-github/dev
Изменена логика выставления оценок проектам в программах
2 parents 4be98e7 + 95b9b37 commit e61998a

6 files changed

Lines changed: 198 additions & 38 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: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ class Meta:
4242

4343
class ProjectScoreSerializer(serializers.ModelSerializer):
4444
criteria = CriteriaSerializer()
45+
expert_id = serializers.IntegerField(source="user_id", read_only=True)
4546

4647
class Meta:
4748
model = ProjectScore
4849
fields = [
4950
"criteria",
51+
"expert_id",
5052
"value",
5153
]
5254

@@ -61,7 +63,9 @@ class ProjectListForRateSerializer(serializers.ModelSerializer):
6163
views_count = serializers.SerializerMethodField()
6264
criterias = serializers.SerializerMethodField()
6365
scored = serializers.SerializerMethodField()
64-
scored_expert_id = serializers.IntegerField(read_only=True, allow_null=True)
66+
rated_experts = serializers.SerializerMethodField()
67+
rated_count = serializers.SerializerMethodField()
68+
max_rates = serializers.SerializerMethodField()
6569

6670
class Meta:
6771
model = Project
@@ -76,22 +80,54 @@ class Meta:
7680
"region",
7781
"views_count",
7882
"scored",
79-
"scored_expert_id",
83+
"rated_experts",
84+
"rated_count",
85+
"max_rates",
8086
"criterias",
8187
]
8288

8389
def get_views_count(self, obj) -> int:
8490
return get_views_count(obj)
8591

92+
def _get_program_scores(self, obj):
93+
if hasattr(obj, "_program_scores"):
94+
return obj._program_scores
95+
program_id = self.context["view"].kwargs.get("program_id")
96+
return ProjectScore.objects.filter(
97+
project=obj, criteria__partner_program_id=program_id
98+
).select_related("criteria", "user")
99+
100+
def _get_user_scores(self, obj):
101+
scores = self._get_program_scores(obj)
102+
request = self.context.get("request")
103+
if request and getattr(request.user, "is_authenticated", False):
104+
return [score for score in scores if score.user_id == request.user.id]
105+
return []
106+
86107
def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse:
108+
user_scores = self._get_user_scores(obj)
109+
if user_scores:
110+
serializer = ProjectScoreSerializer(user_scores, many=True)
111+
return serializer.data
87112
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)
91-
else:
92-
cirterias = Criteria.objects.filter(partner_program__id=program_id)
93-
serializer = CriteriaSerializer(cirterias, many=True)
113+
criterias = Criteria.objects.filter(partner_program__id=program_id)
114+
serializer = CriteriaSerializer(criterias, many=True)
94115
return serializer.data
95116

96117
def get_scored(self, obj) -> bool:
97-
return bool(obj.scored)
118+
user_scores = self._get_user_scores(obj)
119+
return bool(user_scores)
120+
121+
def get_rated_experts(self, obj) -> list[int]:
122+
program_scores = self._get_program_scores(obj)
123+
return list({score.user_id for score in program_scores})
124+
125+
def get_rated_count(self, obj) -> int:
126+
rated_attr = getattr(obj, "rated_count", None)
127+
if rated_attr is not None:
128+
return rated_attr
129+
program_scores = self._get_program_scores(obj)
130+
return len({score.user_id for score in program_scores})
131+
132+
def get_max_rates(self, obj):
133+
return self.context.get("program_max_rates")

project_rates/views.py

Lines changed: 113 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,63 @@ class ProjectListForRate(generics.ListAPIView):
93133
filterset_class = ProjectFilter
94134
pagination_class = RateProjectsPagination
95135

136+
def post(self, request, *args, **kwargs):
137+
"""Allow POST with filters in JSON body."""
138+
return self.list(request, *args, **kwargs)
139+
140+
def _get_program(self) -> PartnerProgram:
141+
return PartnerProgram.objects.get(pk=self.kwargs.get("program_id"))
142+
143+
def _get_filters(self) -> dict:
144+
"""
145+
Accept filters from JSON body to mirror /partner_programs/<id>/projects/filter/:
146+
{"filters": {"case": ["Кейс 1"]}}
147+
"""
148+
if self.request.method != "POST":
149+
return {}
150+
data = getattr(self.request, "data", None)
151+
body_filters = data.get("filters") if isinstance(data, dict) else {}
152+
return body_filters if isinstance(body_filters, dict) else {}
153+
96154
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-
),
155+
program = self._get_program()
156+
157+
filters_serializer = ProgramProjectFilterRequestSerializer(
158+
data={"filters": self._get_filters()}
159+
)
160+
filters_serializer.is_valid(raise_exception=True)
161+
field_filters = filters_serializer.validated_data.get("filters", {})
162+
163+
try:
164+
program_projects_qs = filter_program_projects_by_field_name(
165+
program, field_filters
166+
)
167+
except ValueError as e:
168+
raise ValidationError({"filters": str(e)})
169+
170+
project_ids = program_projects_qs.values_list("project_id", flat=True)
171+
172+
scores_prefetch = Prefetch(
173+
"scores",
174+
queryset=ProjectScore.objects.filter(
175+
criteria__partner_program=program
176+
).select_related("user"),
177+
to_attr="_program_scores",
110178
)
179+
180+
return (
181+
Project.objects.filter(draft=False, id__in=project_ids)
182+
.annotate(
183+
rated_count=Count(
184+
"scores__user",
185+
filter=Q(scores__criteria__partner_program=program),
186+
distinct=True,
187+
)
188+
)
189+
.prefetch_related(scores_prefetch)
190+
)
191+
192+
def get_serializer_context(self):
193+
context = super().get_serializer_context()
194+
context["program_max_rates"] = self._get_program().max_project_rates
195+
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)