From 88c54b708e864707eca549b7d4e288f19a477cd1 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 27 Jul 2026 00:45:25 +0300 Subject: [PATCH 1/2] Add submission evaluation models --- docs/submission-evaluation-domain.md | 9 + partner_programs/admin.py | 135 ++++++++ .../0023_submission_evaluation_models.py | 294 ++++++++++++++++++ partner_programs/models.py | 245 +++++++++++++++ .../test_submission_evaluation_models.py | 247 +++++++++++++++ 5 files changed, 930 insertions(+) create mode 100644 partner_programs/migrations/0023_submission_evaluation_models.py create mode 100644 partner_programs/tests/test_submission_evaluation_models.py diff --git a/docs/submission-evaluation-domain.md b/docs/submission-evaluation-domain.md index 3ceb3dd4..cdbabba0 100644 --- a/docs/submission-evaluation-domain.md +++ b/docs/submission-evaluation-domain.md @@ -2,6 +2,15 @@ Статус: proposal. +Фактический статус MVP-моделей на 25 июля 2026 года: + +- `SubmissionExpertAssignment`, `Evaluation` и `EvaluationScore` реализованы в + `partner_programs`; +- API и lifecycle service еще не реализованы; +- временно используется существующий `project_rates.Criteria`; +- дедлайном MVP остается существующий `datetime_evaluation_ends`; +- `Result`, ranking и публикация итогов еще не реализованы. + Документ описывает следующий этап React-контура PROCOLLAB: назначение экспертов на конкретные `Submission`, кабинет эксперта и управляемую отправку оценки. RFC является только анализом. Он не меняет модели, миграции, diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 22985d8f..1fe646b0 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -12,6 +12,8 @@ from mailing.views import MailingTemplateRender from partner_programs.models import ( Application, + Evaluation, + EvaluationScore, PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, @@ -19,6 +21,7 @@ PartnerProgramProject, PartnerProgramUserProfile, Submission, + SubmissionExpertAssignment, Team, TeamInvite, TeamMember, @@ -251,6 +254,138 @@ class SubmissionAdmin(admin.ModelAdmin): date_hierarchy = "created_at" +@admin.register(SubmissionExpertAssignment) +class SubmissionExpertAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "submission", + "expert", + "status", + "assigned_by", + "assigned_at", + "completed_at", + "revoked_at", + ) + list_filter = ( + "status", + "submission__program", + "assigned_at", + "completed_at", + "revoked_at", + ) + search_fields = ( + "=submission__id", + "submission__title", + "submission__program__name", + "submission__program__tag", + "expert__user__email", + "expert__user__first_name", + "expert__user__last_name", + "assigned_by__email", + ) + raw_id_fields = ( + "submission", + "expert", + "assigned_by", + "revoked_by", + ) + readonly_fields = ( + "assigned_at", + "created_at", + "updated_at", + ) + list_select_related = ( + "submission", + "submission__program", + "expert", + "expert__user", + "assigned_by", + "revoked_by", + ) + date_hierarchy = "assigned_at" + + +@admin.register(Evaluation) +class EvaluationAdmin(admin.ModelAdmin): + list_display = ( + "submission", + "expert", + "status", + "submitted_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "submission__program", + "submitted_at", + ) + search_fields = ( + "=submission__id", + "submission__title", + "submission__program__name", + "submission__program__tag", + "expert__user__email", + "expert__user__first_name", + "expert__user__last_name", + ) + raw_id_fields = ( + "submission", + "expert", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "submission", + "submission__program", + "expert", + "expert__user", + ) + date_hierarchy = "created_at" + + +@admin.register(EvaluationScore) +class EvaluationScoreAdmin(admin.ModelAdmin): + list_display = ( + "evaluation", + "criterion", + "value", + "criterion_name", + ) + list_filter = ( + "criterion__partner_program", + "criterion_type", + "created_at", + ) + search_fields = ( + "=evaluation__id", + "=evaluation__submission__id", + "criterion__name", + "criterion_name", + "evaluation__expert__user__email", + ) + raw_id_fields = ( + "evaluation", + "criterion", + ) + readonly_fields = ( + "criterion_name", + "criterion_type", + "min_value", + "max_value", + "created_at", + "updated_at", + ) + list_select_related = ( + "evaluation", + "evaluation__submission", + "evaluation__expert", + "criterion", + ) + date_hierarchy = "created_at" + + class PartnerProgramMaterialInline(admin.StackedInline): model = PartnerProgramMaterial extra = 1 diff --git a/partner_programs/migrations/0023_submission_evaluation_models.py b/partner_programs/migrations/0023_submission_evaluation_models.py new file mode 100644 index 00000000..cb20c391 --- /dev/null +++ b/partner_programs/migrations/0023_submission_evaluation_models.py @@ -0,0 +1,294 @@ +# Generated by Django 4.2.11 on 2026-07-25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0022_teaminvite_teaminvite_uniq_pending_invite_team_user"), + ("project_rates", "0003_projectexpertassignment"), + ("users", "0060_alter_userachievement_year"), + ] + + operations = [ + migrations.CreateModel( + name="Evaluation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Черновик"), + ("submitted", "Отправлено"), + ], + default="draft", + max_length=16, + ), + ), + ("comment", models.TextField(blank=True)), + ( + "total_score", + models.DecimalField( + blank=True, + decimal_places=6, + max_digits=18, + null=True, + ), + ), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "expert", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="submission_evaluations", + to="users.expert", + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="evaluations", + to="partner_programs.submission", + ), + ), + ], + options={ + "verbose_name": "Оценка Submission", + "verbose_name_plural": "Оценки Submission", + }, + ), + migrations.CreateModel( + name="SubmissionExpertAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("assigned", "Назначено"), + ("completed", "Завершено"), + ("revoked", "Отозвано"), + ], + default="assigned", + max_length=16, + ), + ), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("revoke_reason", models.TextField(blank=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "assigned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="assigned_submission_experts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "expert", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="submission_assignments", + to="users.expert", + ), + ), + ( + "revoked_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="revoked_submission_experts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="expert_assignments", + to="partner_programs.submission", + ), + ), + ], + options={ + "verbose_name": "Назначение эксперта на Submission", + "verbose_name_plural": "Назначения экспертов на Submission", + }, + ), + migrations.CreateModel( + name="EvaluationScore", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.DecimalField(decimal_places=6, max_digits=18)), + ( + "criterion_name", + models.CharField(editable=False, max_length=50), + ), + ( + "criterion_type", + models.CharField(editable=False, max_length=8), + ), + ( + "min_value", + models.FloatField(blank=True, editable=False, null=True), + ), + ( + "max_value", + models.FloatField(blank=True, editable=False, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "criterion", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="evaluation_scores", + to="project_rates.criteria", + ), + ), + ( + "evaluation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scores", + to="partner_programs.evaluation", + ), + ), + ], + options={ + "verbose_name": "Оценка по критерию", + "verbose_name_plural": "Оценки по критериям", + }, + ), + migrations.AddIndex( + model_name="evaluation", + index=models.Index( + fields=["submission", "status"], + name="eval_submission_status_idx", + ), + ), + migrations.AddIndex( + model_name="evaluation", + index=models.Index( + fields=["expert", "status"], + name="evaluation_expert_status_idx", + ), + ), + migrations.AddConstraint( + model_name="evaluation", + constraint=models.UniqueConstraint( + fields=("submission", "expert"), + name="uniq_evaluation_submission_expert", + ), + ), + migrations.AddConstraint( + model_name="evaluation", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("status", "draft"), ("submitted_at__isnull", True)), + models.Q( + ("status", "submitted"), + ("submitted_at__isnull", False), + ), + _connector="OR", + ), + name="evaluation_status_submitted_at", + ), + ), + migrations.AddIndex( + model_name="submissionexpertassignment", + index=models.Index( + fields=["submission", "status"], + name="subm_assign_status_idx", + ), + ), + migrations.AddIndex( + model_name="submissionexpertassignment", + index=models.Index( + fields=["expert", "status"], + name="expert_assignment_status_idx", + ), + ), + migrations.AddConstraint( + model_name="submissionexpertassignment", + constraint=models.UniqueConstraint( + condition=models.Q(("status__in", ("assigned", "completed"))), + fields=("submission", "expert"), + name="uniq_active_submission_expert_assignment", + ), + ), + migrations.AddConstraint( + model_name="submissionexpertassignment", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("completed_at__isnull", True), + ("revoked_at__isnull", True), + ("status", "assigned"), + ), + models.Q( + ("completed_at__isnull", False), + ("revoked_at__isnull", True), + ("status", "completed"), + ), + models.Q( + ("completed_at__isnull", True), + ("revoked_at__isnull", False), + ("status", "revoked"), + ), + _connector="OR", + ), + name="submission_assignment_status_timestamps", + ), + ), + migrations.AddIndex( + model_name="evaluationscore", + index=models.Index( + fields=["criterion", "created_at"], + name="eval_score_criterion_idx", + ), + ), + migrations.AddConstraint( + model_name="evaluationscore", + constraint=models.UniqueConstraint( + fields=("evaluation", "criterion"), + name="uniq_evaluation_score_criterion", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 8cc1c96f..e95098b1 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -835,6 +835,251 @@ def __str__(self): ) +class SubmissionExpertAssignment(models.Model): + """Назначение эксперта на конкретную зафиксированную сдачу.""" + + STATUS_ASSIGNED = "assigned" + STATUS_COMPLETED = "completed" + STATUS_REVOKED = "revoked" + + STATUS_CHOICES = ( + (STATUS_ASSIGNED, "Назначено"), + (STATUS_COMPLETED, "Завершено"), + (STATUS_REVOKED, "Отозвано"), + ) + + ACTIVE_STATUSES = ( + STATUS_ASSIGNED, + STATUS_COMPLETED, + ) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="expert_assignments", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.PROTECT, + related_name="submission_assignments", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_ASSIGNED, + ) + assigned_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="assigned_submission_experts", + ) + assigned_at = models.DateTimeField(auto_now_add=True) + revoked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="revoked_submission_experts", + null=True, + blank=True, + ) + revoked_at = models.DateTimeField(null=True, blank=True) + revoke_reason = models.TextField(blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Назначение эксперта на Submission" + verbose_name_plural = "Назначения экспертов на Submission" + constraints = [ + models.UniqueConstraint( + fields=["submission", "expert"], + condition=models.Q(status__in=("assigned", "completed")), + name="uniq_active_submission_expert_assignment", + ), + models.CheckConstraint( + check=( + models.Q( + status="assigned", + completed_at__isnull=True, + revoked_at__isnull=True, + ) + | models.Q( + status="completed", + completed_at__isnull=False, + revoked_at__isnull=True, + ) + | models.Q( + status="revoked", + completed_at__isnull=True, + revoked_at__isnull=False, + ) + ), + name="submission_assignment_status_timestamps", + ), + ] + indexes = [ + models.Index( + fields=["submission", "status"], + name="subm_assign_status_idx", + ), + models.Index( + fields=["expert", "status"], + name="expert_assignment_status_idx", + ), + ] + + def __str__(self): + return ( + f"SubmissionExpertAssignment<{self.pk}> " + f"submission={self.submission_id} expert={self.expert_id} " + f"status={self.status}" + ) + + +class Evaluation(models.Model): + """Единая форма оценки конкретной Submission конкретным экспертом.""" + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Черновик"), + (STATUS_SUBMITTED, "Отправлено"), + ) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="evaluations", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.PROTECT, + related_name="submission_evaluations", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + comment = models.TextField(blank=True) + total_score = models.DecimalField( + max_digits=18, + decimal_places=6, + null=True, + blank=True, + ) + submitted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Оценка Submission" + verbose_name_plural = "Оценки Submission" + constraints = [ + models.UniqueConstraint( + fields=["submission", "expert"], + name="uniq_evaluation_submission_expert", + ), + models.CheckConstraint( + check=( + models.Q(status="draft", submitted_at__isnull=True) + | models.Q(status="submitted", submitted_at__isnull=False) + ), + name="evaluation_status_submitted_at", + ), + ] + indexes = [ + models.Index( + fields=["submission", "status"], + name="eval_submission_status_idx", + ), + models.Index( + fields=["expert", "status"], + name="evaluation_expert_status_idx", + ), + ] + + def __str__(self): + return ( + f"Evaluation<{self.pk}> submission={self.submission_id} " + f"expert={self.expert_id} status={self.status}" + ) + + +class EvaluationScore(models.Model): + """Числовая оценка по одному критерию внутри Evaluation.""" + + NUMERIC_CRITERION_TYPES = ("int", "float") + + evaluation = models.ForeignKey( + Evaluation, + on_delete=models.CASCADE, + related_name="scores", + ) + criterion = models.ForeignKey( + "project_rates.Criteria", + on_delete=models.PROTECT, + related_name="evaluation_scores", + ) + value = models.DecimalField(max_digits=18, decimal_places=6) + criterion_name = models.CharField(max_length=50, editable=False) + criterion_type = models.CharField(max_length=8, editable=False) + min_value = models.FloatField(null=True, blank=True, editable=False) + max_value = models.FloatField(null=True, blank=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def _capture_criterion_snapshot(self): + if self._state.adding and self.criterion_id: + self.criterion_name = self.criterion.name + self.criterion_type = self.criterion.type + self.min_value = self.criterion.min_value + self.max_value = self.criterion.max_value + + def clean(self): + super().clean() + if self.criterion_id and self.criterion.type not in self.NUMERIC_CRITERION_TYPES: + raise ValidationError( + {"criterion": "Для EvaluationScore допустим только числовой критерий."} + ) + + def save(self, *args, **kwargs): + self._capture_criterion_snapshot() + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Оценка по критерию" + verbose_name_plural = "Оценки по критериям" + constraints = [ + models.UniqueConstraint( + fields=["evaluation", "criterion"], + name="uniq_evaluation_score_criterion", + ), + ] + indexes = [ + models.Index( + fields=["criterion", "created_at"], + name="eval_score_criterion_idx", + ), + ] + + def __str__(self): + return ( + f"EvaluationScore<{self.pk}> evaluation={self.evaluation_id} " + f"criterion={self.criterion_id} value={self.value}" + ) + + class PartnerProgramUserProfile(models.Model): """ PartnerProgramUserProfile model diff --git a/partner_programs/tests/test_submission_evaluation_models.py b/partner_programs/tests/test_submission_evaluation_models.py new file mode 100644 index 00000000..c81691d0 --- /dev/null +++ b/partner_programs/tests/test_submission_evaluation_models.py @@ -0,0 +1,247 @@ +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db.models.deletion import ProtectedError +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + Submission, + SubmissionExpertAssignment, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +class SubmissionEvaluationModelTestCase(TestCase): + def setUp(self): + self.user = create_user(prefix="evaluation-participant") + self.manager = create_user(prefix="evaluation-manager") + self.program = create_partner_program() + self.application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + self.submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.user, + title="Evaluation solution", + ) + self.expert_user = create_rate_expert( + prefix="submission-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + + +class SubmissionExpertAssignmentModelTests(SubmissionEvaluationModelTestCase): + def create_assignment(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def test_can_create_assigned_assignment_with_defaults(self): + assignment = self.create_assignment() + + self.assertEqual( + assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + self.assertIsNotNone(assignment.assigned_at) + self.assertIsNotNone(assignment.created_at) + self.assertIsNotNone(assignment.updated_at) + self.assertIsNone(assignment.completed_at) + self.assertIsNone(assignment.revoked_at) + self.assertIsNone(assignment.revoked_by) + self.assertEqual(assignment.revoke_reason, "") + + def test_duplicate_assigned_assignment_is_rejected(self): + self.create_assignment() + + with self.assertRaises(ValidationError): + self.create_assignment() + + def test_assigned_assignment_is_rejected_after_completed_assignment(self): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + + with self.assertRaises(ValidationError): + self.create_assignment() + + def test_multiple_revoked_assignments_are_allowed(self): + for reason in ("Переназначение", "Изменение состава экспертов"): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason=reason, + ) + + self.assertEqual( + SubmissionExpertAssignment.objects.filter( + submission=self.submission, + expert=self.expert, + ).count(), + 2, + ) + + def test_completed_assignment_requires_completed_at(self): + with self.assertRaises(ValidationError): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_revoked_assignment_requires_revoked_at(self): + with self.assertRaises(ValidationError): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + ) + + def test_assigned_assignment_rejects_lifecycle_timestamps(self): + for field in ("completed_at", "revoked_at"): + with self.subTest(field=field): + with self.assertRaises(ValidationError): + self.create_assignment(**{field: timezone.now()}) + + +class EvaluationModelTests(SubmissionEvaluationModelTestCase): + def create_evaluation(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + } + values.update(overrides) + return Evaluation.objects.create(**values) + + def test_can_create_draft_evaluation(self): + evaluation = self.create_evaluation() + + self.assertEqual(evaluation.status, Evaluation.STATUS_DRAFT) + self.assertEqual(evaluation.comment, "") + self.assertIsNone(evaluation.total_score) + self.assertIsNone(evaluation.submitted_at) + + def test_submission_and_expert_are_unique(self): + self.create_evaluation() + + with self.assertRaises(ValidationError): + self.create_evaluation() + + def test_draft_evaluation_rejects_submitted_at(self): + with self.assertRaises(ValidationError): + self.create_evaluation(submitted_at=timezone.now()) + + def test_submitted_evaluation_requires_submitted_at(self): + with self.assertRaises(ValidationError): + self.create_evaluation(status=Evaluation.STATUS_SUBMITTED) + + def test_submitted_evaluation_with_submitted_at_is_allowed(self): + submitted_at = timezone.now() + + evaluation = self.create_evaluation( + status=Evaluation.STATUS_SUBMITTED, + submitted_at=submitted_at, + ) + + self.assertEqual(evaluation.submitted_at, submitted_at) + + +class EvaluationScoreModelTests(SubmissionEvaluationModelTestCase): + def setUp(self): + super().setUp() + self.evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + self.criterion = create_rate_criteria( + self.program, + name="Impact", + type="float", + min_value=0.5, + max_value=10.5, + ) + + def create_score(self, **overrides): + values = { + "evaluation": self.evaluation, + "criterion": self.criterion, + "value": Decimal("7.250000"), + } + values.update(overrides) + return EvaluationScore.objects.create(**values) + + def test_can_create_numeric_evaluation_score_with_decimal_value(self): + score = self.create_score() + + self.assertEqual(score.value, Decimal("7.250000")) + self.assertIsInstance(score.value, Decimal) + + def test_evaluation_and_criterion_are_unique(self): + self.create_score() + + with self.assertRaises(ValidationError): + self.create_score(value=Decimal("8.000000")) + + def test_used_criterion_is_protected(self): + self.create_score() + + with self.assertRaises(ProtectedError): + self.criterion.delete() + + def test_criterion_snapshot_is_captured(self): + score = self.create_score() + + self.assertEqual(score.criterion_name, self.criterion.name) + self.assertEqual(score.criterion_type, "float") + self.assertEqual(score.min_value, 0.5) + self.assertEqual(score.max_value, 10.5) + + def test_criterion_changes_do_not_change_snapshot(self): + score = self.create_score() + original_snapshot = ( + score.criterion_name, + score.criterion_type, + score.min_value, + score.max_value, + ) + self.criterion.name = "Updated impact" + self.criterion.type = "int" + self.criterion.min_value = 1 + self.criterion.max_value = 20 + self.criterion.save() + + score.refresh_from_db() + + self.assertEqual( + ( + score.criterion_name, + score.criterion_type, + score.min_value, + score.max_value, + ), + original_snapshot, + ) + + def test_non_numeric_criterion_is_rejected(self): + criterion = create_rate_criteria( + self.program, + name="Comment", + type="str", + ) + + with self.assertRaises(ValidationError) as error: + self.create_score(criterion=criterion) + + self.assertIn("criterion", error.exception.message_dict) From 5cd8f8854b3a3d67504931591d3dbacc28360df7 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 27 Jul 2026 01:09:30 +0300 Subject: [PATCH 2/2] Harden evaluation score validation --- partner_programs/models.py | 23 +++++++-- .../test_submission_evaluation_models.py | 47 ++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/partner_programs/models.py b/partner_programs/models.py index e95098b1..781abfcb 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -1045,12 +1045,27 @@ def _capture_criterion_snapshot(self): self.min_value = self.criterion.min_value self.max_value = self.criterion.max_value + def _get_criterion_type_for_validation(self): + if self._state.adding: + return self.criterion.type if self.criterion_id else None + return self.criterion_type + def clean(self): super().clean() - if self.criterion_id and self.criterion.type not in self.NUMERIC_CRITERION_TYPES: - raise ValidationError( - {"criterion": "Для EvaluationScore допустим только числовой критерий."} - ) + criterion_type = self._get_criterion_type_for_validation() + errors = {} + + if criterion_type and criterion_type not in self.NUMERIC_CRITERION_TYPES: + errors["criterion"] = "Для EvaluationScore допустим только числовой критерий." + if ( + criterion_type == "int" + and self.value is not None + and self.value != self.value.to_integral_value() + ): + errors["value"] = "Для целочисленного критерия укажите целое значение." + + if errors: + raise ValidationError(errors) def save(self, *args, **kwargs): self._capture_criterion_snapshot() diff --git a/partner_programs/tests/test_submission_evaluation_models.py b/partner_programs/tests/test_submission_evaluation_models.py index c81691d0..83258396 100644 --- a/partner_programs/tests/test_submission_evaluation_models.py +++ b/partner_programs/tests/test_submission_evaluation_models.py @@ -188,6 +188,35 @@ def test_can_create_numeric_evaluation_score_with_decimal_value(self): self.assertEqual(score.value, Decimal("7.250000")) self.assertIsInstance(score.value, Decimal) + def test_int_criterion_rejects_fractional_decimal(self): + criterion = create_rate_criteria( + self.program, + name="Integer impact", + type="int", + ) + + with self.assertRaises(ValidationError) as error: + self.create_score( + criterion=criterion, + value=Decimal("7.5"), + ) + + self.assertIn("value", error.exception.message_dict) + + def test_int_criterion_accepts_whole_decimal(self): + criterion = create_rate_criteria( + self.program, + name="Integer impact", + type="int", + ) + + score = self.create_score( + criterion=criterion, + value=Decimal("7"), + ) + + self.assertEqual(score.value, Decimal("7")) + def test_evaluation_and_criterion_are_unique(self): self.create_score() @@ -208,7 +237,19 @@ def test_criterion_snapshot_is_captured(self): self.assertEqual(score.min_value, 0.5) self.assertEqual(score.max_value, 10.5) - def test_criterion_changes_do_not_change_snapshot(self): + def test_existing_score_uses_snapshot_type_after_criterion_changes(self): + score = self.create_score(value=Decimal("7.5")) + self.criterion.type = "str" + self.criterion.save() + + score.value = Decimal("8.5") + score.save() + score.refresh_from_db() + + self.assertEqual(score.value, Decimal("8.500000")) + self.assertEqual(score.criterion_type, "float") + + def test_existing_score_save_does_not_overwrite_snapshot(self): score = self.create_score() original_snapshot = ( score.criterion_name, @@ -217,11 +258,13 @@ def test_criterion_changes_do_not_change_snapshot(self): score.max_value, ) self.criterion.name = "Updated impact" - self.criterion.type = "int" + self.criterion.type = "str" self.criterion.min_value = 1 self.criterion.max_value = 20 self.criterion.save() + score.value = Decimal("8.5") + score.save() score.refresh_from_db() self.assertEqual(