diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 28a7f4f2..75390aea 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -81,6 +81,7 @@ class Meta: "projects_availability", "publish_projects_after_finish", "max_project_rates", + "is_distributed_evaluation", "draft", ( "datetime_started", diff --git a/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py b/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py new file mode 100644 index 00000000..8516a66a --- /dev/null +++ b/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py @@ -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='Распределенное оценивание'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 75054e1f..b1cf5679 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -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="Ключи - имена полей, значения - тип поля ввода", diff --git a/project_rates/admin.py b/project_rates/admin.py index 1ecb1455..ba22e4b3 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -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. @@ -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, + ) diff --git a/project_rates/migrations/0003_projectexpertassignment.py b/project_rates/migrations/0003_projectexpertassignment.py new file mode 100644 index 00000000..50b6e832 --- /dev/null +++ b/project_rates/migrations/0003_projectexpertassignment.py @@ -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')}, + }, + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index f2743779..e086cd28 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -1,8 +1,9 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from .constants import VERBOSE_TYPES -from partner_programs.models import PartnerProgram +from partner_programs.models import PartnerProgram, PartnerProgramProject from projects.models import Project from .validators import ProjectScoreValidator @@ -93,3 +94,88 @@ class Meta: verbose_name = "Оценка проекта" verbose_name_plural = "Оценки проектов" unique_together = ("criteria", "user", "project") + + +class ProjectExpertAssignment(models.Model): + partner_program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="project_expert_assignments", + ) + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="expert_assignments", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.CASCADE, + related_name="project_assignments", + ) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Назначение проекта эксперту" + verbose_name_plural = "Назначения проектов экспертам" + unique_together = ("partner_program", "project", "expert") + indexes = [ + models.Index(fields=["partner_program", "project"]), + models.Index(fields=["partner_program", "expert"]), + ] + + def __str__(self): + return ( + f"Assignment<{self.id}> program={self.partner_program_id} " + f"project={self.project_id} expert={self.expert_id}" + ) + + def has_scores(self) -> bool: + return ProjectScore.objects.filter( + project_id=self.project_id, + user_id=self.expert.user_id, + criteria__partner_program_id=self.partner_program_id, + ).exists() + + def clean(self): + errors = {} + + if self.expert_id and self.partner_program_id and not self.expert.programs.filter( + id=self.partner_program_id + ).exists(): + errors["expert"] = "Эксперт не состоит в указанной программе." + + if self.project_id and self.partner_program_id and not PartnerProgramProject.objects.filter( + partner_program_id=self.partner_program_id, + project_id=self.project_id, + ).exists(): + errors["project"] = "Проект не привязан к указанной программе." + + if self.partner_program_id and self.project_id: + max_rates = self.partner_program.max_project_rates + if max_rates: + assignments_qs = ProjectExpertAssignment.objects.filter( + partner_program_id=self.partner_program_id, + project_id=self.project_id, + ) + if self.pk: + assignments_qs = assignments_qs.exclude(pk=self.pk) + if assignments_qs.count() >= max_rates: + errors["partner_program"] = ( + "Достигнуто максимальное количество назначенных экспертов " + "для этого проекта в программе." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + if self.has_scores(): + raise ValidationError( + "Нельзя удалить назначение: эксперт уже оценил этот проект." + ) + return super().delete(*args, **kwargs) diff --git a/project_rates/tests.py b/project_rates/tests.py new file mode 100644 index 00000000..59e7ea12 --- /dev/null +++ b/project_rates/tests.py @@ -0,0 +1,301 @@ +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Project +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from users.models import CustomUser + + +class DistributedEvaluationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + now = timezone.now() + + self.expert_user = CustomUser.objects.create_user( + email="expert@example.com", + password="pass", + first_name="Expert", + last_name="User", + birthday="1990-01-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.other_expert_user = CustomUser.objects.create_user( + email="expert2@example.com", + password="pass", + first_name="Second", + last_name="Expert", + birthday="1991-01-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.leader = CustomUser.objects.create_user( + email="leader@example.com", + password="pass", + first_name="Leader", + last_name="User", + birthday="1992-01-01", + user_type=CustomUser.MEMBER, + is_active=True, + ) + + self.program = PartnerProgram.objects.create( + name="Program", + tag="program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=10), + datetime_started=now - timezone.timedelta(days=1), + datetime_finished=now + timezone.timedelta(days=30), + max_project_rates=2, + ) + self.expert_user.expert.programs.add(self.program) + self.other_expert_user.expert.programs.add(self.program) + + self.project_1 = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project 1", + ) + self.project_2 = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project 2", + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project_1, + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project_2, + ) + + self.criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def _rate_url(self, project_id: int) -> str: + return f"/rate-project/rate/{project_id}" + + def test_list_projects_without_distribution_returns_all_program_projects(self): + self.client.force_authenticate(self.expert_user) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) + + def test_list_projects_with_distribution_returns_only_assigned_projects(self): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_2, + expert=self.other_expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertListEqual(returned_ids, [self.project_1.id]) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], "you are not assigned to rate this project" + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + ProjectScore.objects.filter( + project=self.project_1, + user=self.expert_user, + criteria=self.criteria, + value="8", + ).exists() + ) + mock_delay.assert_called_once() + + +class ProjectExpertAssignmentModelTests(TestCase): + def setUp(self): + now = timezone.now() + self.program = PartnerProgram.objects.create( + name="Program", + tag="program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=10), + datetime_started=now - timezone.timedelta(days=1), + datetime_finished=now + timezone.timedelta(days=30), + max_project_rates=1, + ) + + self.leader = CustomUser.objects.create_user( + email="leader2@example.com", + password="pass", + first_name="Leader", + last_name="Two", + birthday="1993-01-01", + user_type=CustomUser.MEMBER, + is_active=True, + ) + self.project = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project", + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project, + ) + + self.expert_1_user = CustomUser.objects.create_user( + email="model-expert-1@example.com", + password="pass", + first_name="Model", + last_name="Expert1", + birthday="1990-02-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.expert_2_user = CustomUser.objects.create_user( + email="model-expert-2@example.com", + password="pass", + first_name="Model", + last_name="Expert2", + birthday="1990-03-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.expert_1_user.expert.programs.add(self.program) + self.expert_2_user.expert.programs.add(self.program) + + def test_assignment_requires_expert_in_program(self): + self.expert_1_user.expert.programs.remove(self.program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_requires_project_link_to_program(self): + other_program = PartnerProgram.objects.create( + name="Other Program", + tag="other_program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=timezone.now() + timezone.timedelta(days=10), + datetime_started=timezone.now() - timezone.timedelta(days=1), + datetime_finished=timezone.now() + timezone.timedelta(days=30), + ) + self.expert_1_user.expert.programs.add(other_program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=other_program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_respects_max_project_rates_limit(self): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_2_user.expert, + ) + + def test_assignment_cannot_be_deleted_after_scoring(self): + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + ProjectScore.objects.create( + criteria=criteria, + user=self.expert_1_user, + project=self.project, + value="7", + ) + + with self.assertRaises(ValidationError): + assignment.delete() diff --git a/project_rates/views.py b/project_rates/views.py index 46309be3..9e6f94d7 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -13,7 +13,7 @@ from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Project from projects.filters import ProjectFilter -from project_rates.models import Criteria, ProjectScore +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore from project_rates.pagination import RateProjectsPagination from project_rates.serializers import ( ProjectScoreCreateSerializer, @@ -64,6 +64,13 @@ def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]: ).exists(): raise ValueError("Project is not linked to the program") + if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id=project_id, + expert__user_id=user_id, + ).exists(): + raise ValueError("you are not assigned to rate this project") + return data, criteria_to_get, program def create(self, request, *args, **kwargs) -> Response: @@ -177,8 +184,15 @@ def get_queryset(self) -> QuerySet[Project]: to_attr="_program_scores", ) + projects_qs = Project.objects.filter(draft=False, id__in=project_ids) + if program.is_distributed_evaluation: + projects_qs = projects_qs.filter( + expert_assignments__partner_program=program, + expert_assignments__expert__user=self.request.user, + ) + return ( - Project.objects.filter(draft=False, id__in=project_ids) + projects_qs .annotate( rated_count=Count( "scores__user", @@ -187,6 +201,7 @@ def get_queryset(self) -> QuerySet[Project]: ) ) .prefetch_related(scores_prefetch) + .distinct() ) def get_serializer_context(self):