Skip to content

Commit 92fb824

Browse files
committed
Добалвена модель связи экспертов с программами и распределённое оценивание проектов
1 parent dd1cd4e commit 92fb824

8 files changed

Lines changed: 711 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: 246 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,236 @@ 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+
def get_form(self, request, obj=None, **kwargs):
168+
if obj is None:
169+
kwargs["form"] = ProjectExpertAssignmentBulkAddForm
170+
return super().get_form(request, obj, **kwargs)
171+
172+
def get_fields(self, request, obj=None):
173+
if obj is None:
174+
return ("partner_program", "expert", "projects")
175+
return ("partner_program", "project", "expert")
176+
177+
def save_model(self, request, obj, form, change):
178+
if change:
179+
return super().save_model(request, obj, form, change)
180+
181+
program = form.cleaned_data["partner_program"]
182+
expert = form.cleaned_data["expert"]
183+
projects = list(form.cleaned_data["projects"])
184+
selected_project_ids = [project.id for project in projects]
185+
186+
existing_ids = set(
187+
ProjectExpertAssignment.objects.filter(
188+
partner_program=program,
189+
expert=expert,
190+
project_id__in=selected_project_ids,
191+
).values_list("project_id", flat=True)
192+
)
193+
194+
blocked_by_limit_ids = set()
195+
if program.max_project_rates:
196+
blocked_by_limit_ids = set(
197+
ProjectExpertAssignment.objects.filter(
198+
partner_program=program,
199+
project_id__in=selected_project_ids,
200+
)
201+
.values("project_id")
202+
.annotate(total=Count("id"))
203+
.filter(total__gte=program.max_project_rates)
204+
.values_list("project_id", flat=True)
205+
)
206+
207+
actionable_projects = [
208+
project
209+
for project in projects
210+
if project.id not in existing_ids and project.id not in blocked_by_limit_ids
211+
]
212+
213+
primary_project = actionable_projects[0]
214+
obj.partner_program = program
215+
obj.expert = expert
216+
obj.project = primary_project
217+
super().save_model(request, obj, form, change)
218+
219+
created = 1
220+
skipped = len(existing_ids)
221+
failed = len(blocked_by_limit_ids)
222+
223+
for project in actionable_projects[1:]:
224+
try:
225+
ProjectExpertAssignment.objects.create(
226+
partner_program=program,
227+
project=project,
228+
expert=expert,
229+
)
230+
created += 1
231+
except DjangoValidationError:
232+
failed += 1
233+
234+
self.message_user(
235+
request,
236+
(
237+
f"Назначения обработаны. Создано: {created}, "
238+
f"уже существовало: {skipped}, с ошибкой: {failed}."
239+
),
240+
level=messages.SUCCESS if failed == 0 else messages.WARNING,
241+
)
242+
243+
def delete_view(self, request, object_id, extra_context=None):
244+
obj = self.get_object(request, object_id)
245+
if obj and obj.has_scores():
246+
self.message_user(
247+
request,
248+
"Нельзя удалить назначение: эксперт уже оценил этот проект.",
249+
level=messages.ERROR,
250+
)
251+
return redirect(
252+
reverse(
253+
"admin:project_rates_projectexpertassignment_change",
254+
args=[object_id],
255+
)
256+
)
257+
return super().delete_view(request, object_id, extra_context=extra_context)
258+
259+
def delete_queryset(self, request, queryset):
260+
blocked = 0
261+
for obj in queryset:
262+
if obj.has_scores():
263+
blocked += 1
264+
continue
265+
obj.delete()
266+
if blocked:
267+
self.message_user(
268+
request,
269+
(
270+
"Часть назначений не удалена, потому что по ним уже выставлены оценки: "
271+
f"{blocked}"
272+
),
273+
level=messages.WARNING,
274+
)
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)