Skip to content

Commit bcf7616

Browse files
authored
Merge pull request #604 from PROCOLLAB-github/feature_project_list_issuance_update
Распределенное оценивание проектов для экспертов
2 parents dd1cd4e + cd80564 commit bcf7616

8 files changed

Lines changed: 712 additions & 5 deletions

File tree

partner_programs/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Meta:
8181
"projects_availability",
8282
"publish_projects_after_finish",
8383
"max_project_rates",
84+
"is_distributed_evaluation",
8485
"draft",
8586
(
8687
"datetime_started",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.11 on 2026-02-12 06:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('partner_programs', '0015_partnerprogram_publish_projects_after_finish'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='partnerprogram',
15+
name='is_distributed_evaluation',
16+
field=models.BooleanField(default=False, help_text='Если включено, проекты для оценки доступны только назначенным экспертам', verbose_name='Распределенное оценивание'),
17+
),
18+
]

partner_programs/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ class PartnerProgram(models.Model):
8787
verbose_name="Максимальное количество оценок проектов",
8888
help_text="Ограничение на число экспертов, которые могут оценить один проект в программе",
8989
)
90+
is_distributed_evaluation = models.BooleanField(
91+
default=False,
92+
verbose_name="Распределенное оценивание",
93+
help_text=(
94+
"Если включено, проекты для оценки доступны только назначенным экспертам"
95+
),
96+
)
9097
data_schema = models.JSONField(
9198
verbose_name="Схема данных в формате JSON",
9299
help_text="Ключи - имена полей, значения - тип поля ввода",

project_rates/admin.py

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
from django.contrib import admin
2-
from .models import Criteria, ProjectScore
1+
from django import forms
2+
from django.contrib import admin, messages
3+
from django.db.models import Count
4+
from django.contrib.admin.widgets import FilteredSelectMultiple
5+
from django.core.exceptions import ValidationError as DjangoValidationError
6+
from django.shortcuts import redirect
7+
from django.urls import reverse
8+
9+
from partner_programs.models import PartnerProgramProject
10+
from projects.models import Project
11+
from users.models import Expert
12+
13+
from .models import Criteria, ProjectExpertAssignment, ProjectScore
314

415

516
# Register your models here.
@@ -28,3 +39,237 @@ def get_criteria_name(self, obj):
2839

2940
def get_project_name(self, obj):
3041
return obj.project.name
42+
43+
44+
class ProjectExpertAssignmentBulkAddForm(forms.ModelForm):
45+
partner_program = forms.ModelChoiceField(
46+
queryset=ProjectExpertAssignment._meta.get_field("partner_program")
47+
.remote_field.model.objects.all()
48+
.order_by("name"),
49+
label="Программа",
50+
)
51+
expert = forms.ModelChoiceField(queryset=Expert.objects.none(), label="Эксперт")
52+
projects = forms.ModelMultipleChoiceField(
53+
queryset=Project.objects.none(),
54+
label="Проекты",
55+
widget=FilteredSelectMultiple("Проекты", is_stacked=False),
56+
)
57+
58+
class Meta:
59+
model = ProjectExpertAssignment
60+
fields = ("partner_program", "expert")
61+
62+
def __init__(self, *args, **kwargs):
63+
super().__init__(*args, **kwargs)
64+
self.fields["expert"].queryset = Expert.objects.select_related("user").order_by(
65+
"user__last_name", "user__first_name"
66+
)
67+
self.fields["projects"].queryset = (
68+
Project.objects.filter(program_links__isnull=False, draft=False)
69+
.distinct()
70+
.order_by("name")
71+
)
72+
73+
program_id = None
74+
if self.is_bound:
75+
program_id = self.data.get("partner_program")
76+
else:
77+
program_id = self.initial.get("partner_program")
78+
79+
if program_id:
80+
self.fields["expert"].queryset = (
81+
Expert.objects.filter(programs__id=program_id)
82+
.select_related("user")
83+
.order_by("user__last_name", "user__first_name")
84+
)
85+
self.fields["projects"].queryset = (
86+
Project.objects.filter(
87+
program_links__partner_program_id=program_id,
88+
draft=False,
89+
)
90+
.distinct()
91+
.order_by("name")
92+
)
93+
94+
def clean(self):
95+
cleaned_data = super().clean()
96+
program = cleaned_data.get("partner_program")
97+
expert = cleaned_data.get("expert")
98+
projects = cleaned_data.get("projects")
99+
100+
if not program or not expert or not projects:
101+
return cleaned_data
102+
103+
if not expert.programs.filter(id=program.id).exists():
104+
self.add_error("expert", "Эксперт не состоит в выбранной программе.")
105+
106+
linked_project_ids = set(
107+
PartnerProgramProject.objects.filter(
108+
partner_program=program,
109+
project_id__in=projects.values_list("id", flat=True),
110+
).values_list("project_id", flat=True)
111+
)
112+
if len(linked_project_ids) != len(projects):
113+
self.add_error("projects", "Выбраны проекты, не привязанные к программе.")
114+
115+
selected_project_ids = list(projects.values_list("id", flat=True))
116+
existing_ids = set(
117+
ProjectExpertAssignment.objects.filter(
118+
partner_program=program,
119+
expert=expert,
120+
project_id__in=selected_project_ids,
121+
).values_list("project_id", flat=True)
122+
)
123+
124+
blocked_by_limit_ids = set()
125+
if program.max_project_rates:
126+
blocked_by_limit_ids = set(
127+
ProjectExpertAssignment.objects.filter(
128+
partner_program=program,
129+
project_id__in=selected_project_ids,
130+
)
131+
.values("project_id")
132+
.annotate(total=Count("id"))
133+
.filter(total__gte=program.max_project_rates)
134+
.values_list("project_id", flat=True)
135+
)
136+
137+
actionable = [
138+
project_id
139+
for project_id in selected_project_ids
140+
if project_id not in existing_ids and project_id not in blocked_by_limit_ids
141+
]
142+
if not actionable:
143+
raise forms.ValidationError(
144+
"Нет проектов для нового назначения: все уже назначены или достигли лимита."
145+
)
146+
147+
return cleaned_data
148+
149+
150+
@admin.register(ProjectExpertAssignment)
151+
class ProjectExpertAssignmentAdmin(admin.ModelAdmin):
152+
list_display = (
153+
"id",
154+
"partner_program",
155+
"project",
156+
"expert",
157+
"datetime_created",
158+
)
159+
list_filter = ("partner_program",)
160+
search_fields = (
161+
"project__name",
162+
"partner_program__name",
163+
"expert__user__first_name",
164+
"expert__user__last_name",
165+
"expert__user__email",
166+
)
167+
168+
def get_form(self, request, obj=None, **kwargs):
169+
if obj is None:
170+
kwargs["form"] = ProjectExpertAssignmentBulkAddForm
171+
return super().get_form(request, obj, **kwargs)
172+
173+
def get_fields(self, request, obj=None):
174+
if obj is None:
175+
return ("partner_program", "expert", "projects")
176+
return ("partner_program", "project", "expert")
177+
178+
def save_model(self, request, obj, form, change):
179+
if change:
180+
return super().save_model(request, obj, form, change)
181+
182+
program = form.cleaned_data["partner_program"]
183+
expert = form.cleaned_data["expert"]
184+
projects = list(form.cleaned_data["projects"])
185+
selected_project_ids = [project.id for project in projects]
186+
187+
existing_ids = set(
188+
ProjectExpertAssignment.objects.filter(
189+
partner_program=program,
190+
expert=expert,
191+
project_id__in=selected_project_ids,
192+
).values_list("project_id", flat=True)
193+
)
194+
195+
blocked_by_limit_ids = set()
196+
if program.max_project_rates:
197+
blocked_by_limit_ids = set(
198+
ProjectExpertAssignment.objects.filter(
199+
partner_program=program,
200+
project_id__in=selected_project_ids,
201+
)
202+
.values("project_id")
203+
.annotate(total=Count("id"))
204+
.filter(total__gte=program.max_project_rates)
205+
.values_list("project_id", flat=True)
206+
)
207+
208+
actionable_projects = [
209+
project
210+
for project in projects
211+
if project.id not in existing_ids and project.id not in blocked_by_limit_ids
212+
]
213+
214+
primary_project = actionable_projects[0]
215+
obj.partner_program = program
216+
obj.expert = expert
217+
obj.project = primary_project
218+
super().save_model(request, obj, form, change)
219+
220+
created = 1
221+
skipped = len(existing_ids)
222+
failed = len(blocked_by_limit_ids)
223+
224+
for project in actionable_projects[1:]:
225+
try:
226+
ProjectExpertAssignment.objects.create(
227+
partner_program=program,
228+
project=project,
229+
expert=expert,
230+
)
231+
created += 1
232+
except DjangoValidationError:
233+
failed += 1
234+
235+
self.message_user(
236+
request,
237+
(
238+
f"Назначения обработаны. Создано: {created}, "
239+
f"уже существовало: {skipped}, с ошибкой: {failed}."
240+
),
241+
level=messages.SUCCESS if failed == 0 else messages.WARNING,
242+
)
243+
244+
def delete_view(self, request, object_id, extra_context=None):
245+
obj = self.get_object(request, object_id)
246+
if obj and obj.has_scores():
247+
self.message_user(
248+
request,
249+
"Нельзя удалить назначение: эксперт уже оценил этот проект.",
250+
level=messages.ERROR,
251+
)
252+
return redirect(
253+
reverse(
254+
"admin:project_rates_projectexpertassignment_change",
255+
args=[object_id],
256+
)
257+
)
258+
return super().delete_view(request, object_id, extra_context=extra_context)
259+
260+
def delete_queryset(self, request, queryset):
261+
blocked = 0
262+
for obj in queryset:
263+
if obj.has_scores():
264+
blocked += 1
265+
continue
266+
obj.delete()
267+
if blocked:
268+
self.message_user(
269+
request,
270+
(
271+
"Часть назначений не удалена, потому что по ним уже выставлены оценки: "
272+
f"{blocked}"
273+
),
274+
level=messages.WARNING,
275+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 4.2.11 on 2026-02-12 06:41
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('users', '0060_alter_userachievement_year'),
11+
('partner_programs', '0016_partnerprogram_is_distributed_evaluation'),
12+
('projects', '0032_hide_program_projects'),
13+
('project_rates', '0002_remove_projectscore_comment'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='ProjectExpertAssignment',
19+
fields=[
20+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('datetime_created', models.DateTimeField(auto_now_add=True)),
22+
('datetime_updated', models.DateTimeField(auto_now=True)),
23+
('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignments', to='users.expert')),
24+
('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_expert_assignments', to='partner_programs.partnerprogram')),
25+
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_assignments', to='projects.project')),
26+
],
27+
options={
28+
'verbose_name': 'Назначение проекта эксперту',
29+
'verbose_name_plural': 'Назначения проектов экспертам',
30+
'indexes': [models.Index(fields=['partner_program', 'project'], name='project_rat_partner_85f175_idx'), models.Index(fields=['partner_program', 'expert'], name='project_rat_partner_aae584_idx')],
31+
'unique_together': {('partner_program', 'project', 'expert')},
32+
},
33+
),
34+
]

0 commit comments

Comments
 (0)