Skip to content

Commit 7012cae

Browse files
committed
Бизнес-логика project_rates вынесена в сервисы
1 parent 9ed1c89 commit 7012cae

5 files changed

Lines changed: 224 additions & 173 deletions

File tree

docs/modules/project-rates.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
## Статус модуля
1313

1414
Модуль рабочий и используется вместе с `partner_programs`. Базовые сценарии
15-
зафиксированы regression-тестами, но бизнес-логика оценки пока находится во
16-
`views.py`. Перед крупным рефакторингом важно сохранять текущий API-контракт и
17-
правила доступа экспертов.
15+
зафиксированы regression-тестами, бизнес-логика оценки вынесена в service
16+
layer. При дальнейших изменениях важно сохранять текущий API-контракт и правила
17+
доступа экспертов.
1818

1919
## Основные возможности
2020

@@ -33,6 +33,8 @@
3333

3434
- `project_rates/models.py` - критерии, оценки и назначения проектов экспертам.
3535
- `project_rates/views.py` - API оценки проекта и списка проектов для оценки.
36+
- `project_rates/services.py` - бизнес-логика выставления оценок, фильтрации
37+
проектов для оценки и проверки лимитов.
3638
- `project_rates/serializers.py` - request serializer оценки и response
3739
serializer списка проектов.
3840
- `project_rates/validators.py` - проверка типа значения и числовых границ
@@ -149,7 +151,8 @@ Excel-выгрузки оценок программы через `/programs/<id
149151
- `max_project_rates` ограничивает число разных экспертов, которые могут
150152
оценить один проект в программе; текущий эксперт может обновить свою оценку.
151153
- Дефолтный критерий `Комментарий` создается сигналом при создании программы.
152-
- Основная бизнес-логика оценки пока находится во `project_rates/views.py`.
154+
- Основная бизнес-логика оценки вынесена в `project_rates/services.py`; views
155+
отвечают за HTTP-контракт и преобразование ошибок в response.
153156

154157
## Тесты
155158

project_rates/services.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from django.db import transaction
2+
from django.db.models import Count, Prefetch, Q, QuerySet
3+
4+
from rest_framework.exceptions import ValidationError
5+
6+
from partner_programs.models import PartnerProgram, PartnerProgramProject
7+
from partner_programs.serializers import ProgramProjectFilterRequestSerializer
8+
from partner_programs.utils import filter_program_projects_by_field_name
9+
from projects.models import Project
10+
from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore
11+
from project_rates.serializers import ProjectScoreCreateSerializer
12+
from users.models import Expert
13+
from vacancy.mapping import ProjectRatedParams, MessageTypeEnum
14+
from vacancy.tasks import send_email
15+
16+
17+
class MaxProjectRatesReached(Exception):
18+
def __init__(self, max_project_rates: int):
19+
self.max_project_rates = max_project_rates
20+
super().__init__("max project rates reached for this program")
21+
22+
23+
def get_rate_program(program_id: int) -> PartnerProgram:
24+
return PartnerProgram.objects.get(pk=program_id)
25+
26+
27+
def extract_project_rate_filters(method: str, data) -> dict:
28+
"""
29+
Accept filters from JSON body to mirror /partner_programs/<id>/projects/filter/:
30+
{"filters": {"case": ["Кейс 1"]}}
31+
"""
32+
if method != "POST":
33+
return {}
34+
35+
body_filters = data.get("filters") if isinstance(data, dict) else {}
36+
return body_filters if isinstance(body_filters, dict) else {}
37+
38+
39+
def get_projects_for_rate_queryset(
40+
*,
41+
program: PartnerProgram,
42+
user,
43+
field_filters: dict,
44+
) -> QuerySet[Project]:
45+
filters_serializer = ProgramProjectFilterRequestSerializer(
46+
data={"filters": field_filters}
47+
)
48+
filters_serializer.is_valid(raise_exception=True)
49+
validated_filters = filters_serializer.validated_data.get("filters", {})
50+
51+
try:
52+
program_projects_qs = filter_program_projects_by_field_name(
53+
program, validated_filters
54+
)
55+
except ValueError as e:
56+
raise ValidationError({"filters": str(e)})
57+
58+
project_ids = program_projects_qs.values_list("project_id", flat=True)
59+
60+
scores_prefetch = Prefetch(
61+
"scores",
62+
queryset=ProjectScore.objects.filter(
63+
criteria__partner_program=program
64+
).select_related("user"),
65+
to_attr="_program_scores",
66+
)
67+
68+
projects_qs = Project.objects.filter(draft=False, id__in=project_ids)
69+
if program.is_distributed_evaluation:
70+
projects_qs = projects_qs.filter(
71+
expert_assignments__partner_program=program,
72+
expert_assignments__expert__user=user,
73+
)
74+
75+
return (
76+
projects_qs
77+
.annotate(
78+
rated_count=Count(
79+
"scores__user",
80+
filter=Q(scores__criteria__partner_program=program),
81+
distinct=True,
82+
)
83+
)
84+
.prefetch_related(scores_prefetch)
85+
.distinct()
86+
)
87+
88+
89+
def submit_project_scores(*, user, project_id: int, data) -> None:
90+
rating_data, criteria_ids, program = _prepare_project_score_data(
91+
user=user,
92+
project_id=project_id,
93+
data=data,
94+
)
95+
96+
serializer = ProjectScoreCreateSerializer(
97+
data=rating_data,
98+
criteria_to_get=criteria_ids,
99+
many=True,
100+
)
101+
serializer.is_valid(raise_exception=True)
102+
103+
scores_qs = ProjectScore.objects.filter(
104+
project_id=project_id,
105+
criteria__partner_program=program,
106+
)
107+
user_has_scores = scores_qs.filter(user_id=user.id).exists()
108+
109+
if program.max_project_rates:
110+
distinct_raters = scores_qs.values("user_id").distinct().count()
111+
if not user_has_scores and distinct_raters >= program.max_project_rates:
112+
raise MaxProjectRatesReached(program.max_project_rates)
113+
114+
with transaction.atomic():
115+
ProjectScore.objects.bulk_create(
116+
[ProjectScore(**item) for item in serializer.validated_data],
117+
update_conflicts=True,
118+
update_fields=["value"],
119+
unique_fields=["criteria", "user", "project"],
120+
)
121+
122+
project = Project.objects.select_related("leader").get(id=project_id)
123+
_send_project_rated_email(project=project, program=program)
124+
125+
126+
def _prepare_project_score_data(*, user, project_id: int, data) -> tuple[list, list, PartnerProgram]:
127+
rating_data = [dict(criterion) for criterion in data]
128+
criteria_ids = [criterion["criterion_id"] for criterion in rating_data]
129+
130+
criteria_qs = Criteria.objects.filter(id__in=criteria_ids).select_related(
131+
"partner_program"
132+
)
133+
partner_program_ids = (
134+
criteria_qs.values_list("partner_program_id", flat=True).distinct()
135+
)
136+
if not criteria_qs.exists():
137+
raise ValueError("Criteria not found")
138+
if partner_program_ids.count() != 1:
139+
raise ValueError("All criteria must belong to the same program")
140+
141+
program = criteria_qs.first().partner_program
142+
Expert.objects.get(user__id=user.id, programs=program)
143+
144+
for criterion in rating_data:
145+
criterion["user"] = user.id
146+
criterion["project"] = project_id
147+
criterion["criteria"] = criterion.pop("criterion_id")
148+
149+
if not PartnerProgramProject.objects.filter(
150+
partner_program=program,
151+
project_id=project_id,
152+
).exists():
153+
raise ValueError("Project is not linked to the program")
154+
155+
if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter(
156+
partner_program=program,
157+
project_id=project_id,
158+
expert__user_id=user.id,
159+
).exists():
160+
raise ValueError("you are not assigned to rate this project")
161+
162+
return rating_data, criteria_ids, program
163+
164+
165+
def _send_project_rated_email(*, project: Project, program: PartnerProgram) -> None:
166+
send_email.delay(
167+
ProjectRatedParams(
168+
message_type=MessageTypeEnum.PROJECT_RATED.value,
169+
user_id=project.leader.id,
170+
project_name=project.name,
171+
project_id=project.id,
172+
schema_id=2,
173+
program_name=program.name,
174+
)
175+
)

project_rates/tests/test_distributed_evaluation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_list_projects_with_distribution_returns_only_assigned_projects(self):
7979
returned_ids = [item["id"] for item in response.data["results"]]
8080
self.assertListEqual(returned_ids, [self.project_1.id])
8181

82-
@patch("project_rates.views.send_email.delay")
82+
@patch("project_rates.services.send_email.delay")
8383
def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay):
8484
self.program.is_distributed_evaluation = True
8585
self.program.save(update_fields=["is_distributed_evaluation"])
@@ -97,7 +97,7 @@ def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_de
9797
)
9898
self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists())
9999

100-
@patch("project_rates.views.send_email.delay")
100+
@patch("project_rates.services.send_email.delay")
101101
def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay):
102102
self.program.is_distributed_evaluation = True
103103
self.program.save(update_fields=["is_distributed_evaluation"])

project_rates/tests/test_rate_project_api.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def _rate_url(self, project_id: int | None = None) -> str:
3939
def _payload(self, criteria=None, value: str = "8") -> list[dict]:
4040
return [{"criterion_id": (criteria or self.criteria).id, "value": value}]
4141

42-
@patch("project_rates.views.send_email.delay")
42+
@patch("project_rates.services.send_email.delay")
4343
def test_expert_can_rate_project_without_distribution(self, send_email_delay):
4444
self.client.force_authenticate(self.expert)
4545

@@ -61,7 +61,7 @@ def test_expert_can_rate_project_without_distribution(self, send_email_delay):
6161
)
6262
send_email_delay.assert_called_once()
6363

64-
@patch("project_rates.views.send_email.delay")
64+
@patch("project_rates.services.send_email.delay")
6565
def test_expert_can_update_existing_score(self, send_email_delay):
6666
ProjectScore.objects.create(
6767
criteria=self.criteria,
@@ -87,7 +87,7 @@ def test_expert_can_update_existing_score(self, send_email_delay):
8787
self.assertEqual(scores.get().value, "9")
8888
send_email_delay.assert_called_once()
8989

90-
@patch("project_rates.views.send_email.delay")
90+
@patch("project_rates.services.send_email.delay")
9191
def test_rate_project_rejects_expert_without_program_membership(
9292
self,
9393
send_email_delay,
@@ -109,7 +109,7 @@ def test_rate_project_rejects_expert_without_program_membership(
109109
self.assertFalse(ProjectScore.objects.filter(user=outsider).exists())
110110
send_email_delay.assert_not_called()
111111

112-
@patch("project_rates.views.send_email.delay")
112+
@patch("project_rates.services.send_email.delay")
113113
def test_rate_project_rejects_project_not_linked_to_program(
114114
self,
115115
send_email_delay,
@@ -131,7 +131,7 @@ def test_rate_project_rejects_project_not_linked_to_program(
131131
self.assertFalse(ProjectScore.objects.filter(project=unlinked_project).exists())
132132
send_email_delay.assert_not_called()
133133

134-
@patch("project_rates.views.send_email.delay")
134+
@patch("project_rates.services.send_email.delay")
135135
def test_rate_project_rejects_criteria_from_different_programs(
136136
self,
137137
send_email_delay,
@@ -157,7 +157,7 @@ def test_rate_project_rejects_criteria_from_different_programs(
157157
self.assertFalse(ProjectScore.objects.filter(project=self.project).exists())
158158
send_email_delay.assert_not_called()
159159

160-
@patch("project_rates.views.send_email.delay")
160+
@patch("project_rates.services.send_email.delay")
161161
def test_rate_project_respects_max_project_rates_for_new_expert(
162162
self,
163163
send_email_delay,
@@ -189,7 +189,7 @@ def test_rate_project_respects_max_project_rates_for_new_expert(
189189
self.assertFalse(ProjectScore.objects.filter(user=self.other_expert).exists())
190190
send_email_delay.assert_not_called()
191191

192-
@patch("project_rates.views.send_email.delay")
192+
@patch("project_rates.services.send_email.delay")
193193
def test_rate_project_validates_numeric_limits(self, send_email_delay):
194194
self.client.force_authenticate(self.expert)
195195

@@ -204,7 +204,7 @@ def test_rate_project_validates_numeric_limits(self, send_email_delay):
204204
self.assertFalse(ProjectScore.objects.filter(project=self.project).exists())
205205
send_email_delay.assert_not_called()
206206

207-
@patch("project_rates.views.send_email.delay")
207+
@patch("project_rates.services.send_email.delay")
208208
def test_rate_project_validates_bool_value(self, send_email_delay):
209209
bool_criteria = create_rate_criteria(self.program, type="bool")
210210
self.client.force_authenticate(self.expert)

0 commit comments

Comments
 (0)