Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class Meta:
"projects_availability",
"publish_projects_after_finish",
"max_project_rates",
"is_distributed_evaluation",
"draft",
(
"datetime_started",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2026-02-12 06:41

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('partner_programs', '0015_partnerprogram_publish_projects_after_finish'),
]

operations = [
migrations.AddField(
model_name='partnerprogram',
name='is_distributed_evaluation',
field=models.BooleanField(default=False, help_text='Если включено, проекты для оценки доступны только назначенным экспертам', verbose_name='Распределенное оценивание'),
),
]
7 changes: 7 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ class PartnerProgram(models.Model):
verbose_name="Максимальное количество оценок проектов",
help_text="Ограничение на число экспертов, которые могут оценить один проект в программе",
)
is_distributed_evaluation = models.BooleanField(
default=False,
verbose_name="Распределенное оценивание",
help_text=(
"Если включено, проекты для оценки доступны только назначенным экспертам"
),
)
data_schema = models.JSONField(
verbose_name="Схема данных в формате JSON",
help_text="Ключи - имена полей, значения - тип поля ввода",
Expand Down
249 changes: 247 additions & 2 deletions project_rates/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from django.contrib import admin
from .models import Criteria, ProjectScore
from django import forms
from django.contrib import admin, messages
from django.db.models import Count
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.core.exceptions import ValidationError as DjangoValidationError
from django.shortcuts import redirect
from django.urls import reverse

from partner_programs.models import PartnerProgramProject
from projects.models import Project
from users.models import Expert

from .models import Criteria, ProjectExpertAssignment, ProjectScore


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

def get_project_name(self, obj):
return obj.project.name


class ProjectExpertAssignmentBulkAddForm(forms.ModelForm):
partner_program = forms.ModelChoiceField(
queryset=ProjectExpertAssignment._meta.get_field("partner_program")
.remote_field.model.objects.all()
.order_by("name"),
label="Программа",
)
expert = forms.ModelChoiceField(queryset=Expert.objects.none(), label="Эксперт")
projects = forms.ModelMultipleChoiceField(
queryset=Project.objects.none(),
label="Проекты",
widget=FilteredSelectMultiple("Проекты", is_stacked=False),
)

class Meta:
model = ProjectExpertAssignment
fields = ("partner_program", "expert")

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["expert"].queryset = Expert.objects.select_related("user").order_by(
"user__last_name", "user__first_name"
)
self.fields["projects"].queryset = (
Project.objects.filter(program_links__isnull=False, draft=False)
.distinct()
.order_by("name")
)

program_id = None
if self.is_bound:
program_id = self.data.get("partner_program")
else:
program_id = self.initial.get("partner_program")

if program_id:
self.fields["expert"].queryset = (
Expert.objects.filter(programs__id=program_id)
.select_related("user")
.order_by("user__last_name", "user__first_name")
)
self.fields["projects"].queryset = (
Project.objects.filter(
program_links__partner_program_id=program_id,
draft=False,
)
.distinct()
.order_by("name")
)

def clean(self):
cleaned_data = super().clean()
program = cleaned_data.get("partner_program")
expert = cleaned_data.get("expert")
projects = cleaned_data.get("projects")

if not program or not expert or not projects:
return cleaned_data

if not expert.programs.filter(id=program.id).exists():
self.add_error("expert", "Эксперт не состоит в выбранной программе.")

linked_project_ids = set(
PartnerProgramProject.objects.filter(
partner_program=program,
project_id__in=projects.values_list("id", flat=True),
).values_list("project_id", flat=True)
)
if len(linked_project_ids) != len(projects):
self.add_error("projects", "Выбраны проекты, не привязанные к программе.")

selected_project_ids = list(projects.values_list("id", flat=True))
existing_ids = set(
ProjectExpertAssignment.objects.filter(
partner_program=program,
expert=expert,
project_id__in=selected_project_ids,
).values_list("project_id", flat=True)
)

blocked_by_limit_ids = set()
if program.max_project_rates:
blocked_by_limit_ids = set(
ProjectExpertAssignment.objects.filter(
partner_program=program,
project_id__in=selected_project_ids,
)
.values("project_id")
.annotate(total=Count("id"))
.filter(total__gte=program.max_project_rates)
.values_list("project_id", flat=True)
)

actionable = [
project_id
for project_id in selected_project_ids
if project_id not in existing_ids and project_id not in blocked_by_limit_ids
]
if not actionable:
raise forms.ValidationError(
"Нет проектов для нового назначения: все уже назначены или достигли лимита."
)

return cleaned_data


@admin.register(ProjectExpertAssignment)
class ProjectExpertAssignmentAdmin(admin.ModelAdmin):
list_display = (
"id",
"partner_program",
"project",
"expert",
"datetime_created",
)
list_filter = ("partner_program",)
search_fields = (
"project__name",
"partner_program__name",
"expert__user__first_name",
"expert__user__last_name",
"expert__user__email",
)

def get_form(self, request, obj=None, **kwargs):
if obj is None:
kwargs["form"] = ProjectExpertAssignmentBulkAddForm
return super().get_form(request, obj, **kwargs)

def get_fields(self, request, obj=None):
if obj is None:
return ("partner_program", "expert", "projects")
return ("partner_program", "project", "expert")

def save_model(self, request, obj, form, change):
if change:
return super().save_model(request, obj, form, change)

program = form.cleaned_data["partner_program"]
expert = form.cleaned_data["expert"]
projects = list(form.cleaned_data["projects"])
selected_project_ids = [project.id for project in projects]

existing_ids = set(
ProjectExpertAssignment.objects.filter(
partner_program=program,
expert=expert,
project_id__in=selected_project_ids,
).values_list("project_id", flat=True)
)

blocked_by_limit_ids = set()
if program.max_project_rates:
blocked_by_limit_ids = set(
ProjectExpertAssignment.objects.filter(
partner_program=program,
project_id__in=selected_project_ids,
)
.values("project_id")
.annotate(total=Count("id"))
.filter(total__gte=program.max_project_rates)
.values_list("project_id", flat=True)
)

actionable_projects = [
project
for project in projects
if project.id not in existing_ids and project.id not in blocked_by_limit_ids
]

primary_project = actionable_projects[0]
obj.partner_program = program
obj.expert = expert
obj.project = primary_project
super().save_model(request, obj, form, change)

created = 1
skipped = len(existing_ids)
failed = len(blocked_by_limit_ids)

for project in actionable_projects[1:]:
try:
ProjectExpertAssignment.objects.create(
partner_program=program,
project=project,
expert=expert,
)
created += 1
except DjangoValidationError:
failed += 1

self.message_user(
request,
(
f"Назначения обработаны. Создано: {created}, "
f"уже существовало: {skipped}, с ошибкой: {failed}."
),
level=messages.SUCCESS if failed == 0 else messages.WARNING,
)

def delete_view(self, request, object_id, extra_context=None):
obj = self.get_object(request, object_id)
if obj and obj.has_scores():
self.message_user(
request,
"Нельзя удалить назначение: эксперт уже оценил этот проект.",
level=messages.ERROR,
)
return redirect(
reverse(
"admin:project_rates_projectexpertassignment_change",
args=[object_id],
)
)
return super().delete_view(request, object_id, extra_context=extra_context)

def delete_queryset(self, request, queryset):
blocked = 0
for obj in queryset:
if obj.has_scores():
blocked += 1
continue
obj.delete()
if blocked:
self.message_user(
request,
(
"Часть назначений не удалена, потому что по ним уже выставлены оценки: "
f"{blocked}"
),
level=messages.WARNING,
)
34 changes: 34 additions & 0 deletions project_rates/migrations/0003_projectexpertassignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 4.2.11 on 2026-02-12 06:41

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('users', '0060_alter_userachievement_year'),
('partner_programs', '0016_partnerprogram_is_distributed_evaluation'),
('projects', '0032_hide_program_projects'),
('project_rates', '0002_remove_projectscore_comment'),
]

operations = [
migrations.CreateModel(
name='ProjectExpertAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime_created', models.DateTimeField(auto_now_add=True)),
('datetime_updated', models.DateTimeField(auto_now=True)),
('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignments', to='users.expert')),
('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_expert_assignments', to='partner_programs.partnerprogram')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_assignments', to='projects.project')),
],
options={
'verbose_name': 'Назначение проекта эксперту',
'verbose_name_plural': 'Назначения проектов экспертам',
'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')],
'unique_together': {('partner_program', 'project', 'expert')},
},
),
]
Loading