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
12 changes: 12 additions & 0 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ def put(self, request, pk):
return Response(save_form(pk, request.user, request.data))


class ManagementCriteriaView(ManagementBase):
def get(self, request, pk):
from partner_programs.services.management_criteria import criteria_data

return Response(criteria_data(managed_program(pk, request.user)))

def put(self, request, pk):
from partner_programs.services.management_criteria import save_criteria

return Response(save_criteria(pk, request.user, request.data))


class ManagementParticipantsView(ManagementBase):
def get(self, request, pk):
program = managed_program(pk, request.user)
Expand Down
33 changes: 33 additions & 0 deletions partner_programs/migrations/0021_evaluation_criteria_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from django.db import migrations, models
from django.utils import timezone


def freeze_existing(apps, schema_editor):
scores = apps.get_model("project_rates", "ProjectScore")
programs = apps.get_model("partner_programs", "PartnerProgram")
ids = scores.objects.using(schema_editor.connection.alias).values_list(
"criteria__partner_program_id", flat=True
)
programs.objects.using(schema_editor.connection.alias).filter(pk__in=ids).update(
evaluation_criteria_locked_at=timezone.now()
)


class Migration(migrations.Migration):
dependencies = [
("partner_programs", "0020_partnerprogram_image_framing"),
("project_rates", "0003_projectexpertassignment"),
]
operations = [
migrations.AddField(
model_name="partnerprogram",
name="evaluation_criteria_locked_at",
field=models.DateTimeField(null=True, editable=False),
),
migrations.AddField(
model_name="partnerprogram",
name="evaluation_criteria_version",
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.RunPython(freeze_existing, migrations.RunPython.noop),
]
2 changes: 2 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class PartnerProgram(models.Model):
"Если включено, проекты для оценки доступны только назначенным экспертам"
),
)
evaluation_criteria_locked_at = models.DateTimeField(null=True, editable=False)
evaluation_criteria_version = models.PositiveIntegerField(default=0, editable=False)
data_schema = models.JSONField(
verbose_name="Схема данных в формате JSON",
help_text="Ключи - имена полей, значения - тип поля ввода",
Expand Down
116 changes: 116 additions & 0 deletions partner_programs/services/management_criteria.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Atomic editing of the shared program criteria, before the first score."""

import math

from django.db import transaction
from django.utils import timezone
from rest_framework import serializers

from partner_programs.models import PartnerProgram
from partner_programs.serializers.management import StrictFieldsMixin
from partner_programs.services.management import managed_program
from project_rates.criteria_policy import CriteriaConflict, definitions, locked, revision
from project_rates.models import Criteria

SUPPORTED_TYPES = ("int", "bool", "str")


class CriterionWrite(StrictFieldsMixin, serializers.Serializer):
id = serializers.IntegerField(required=False, allow_null=True, min_value=1)
name = serializers.CharField(max_length=50)
description = serializers.CharField(
required=False, allow_blank=True, allow_null=True, default="", max_length=4000
)
type = serializers.ChoiceField(choices=(*SUPPORTED_TYPES, "float"))
min_value = serializers.FloatField(allow_null=True)
max_value = serializers.FloatField(allow_null=True)


class CriteriaWrite(StrictFieldsMixin, serializers.Serializer):
expected_updated_at = serializers.DateTimeField()
expected_revision = serializers.CharField(max_length=64)
criteria = CriterionWrite(many=True)

def validate_criteria(self, rows):
ids = [r["id"] for r in rows if r.get("id") is not None]
if len(rows) > 100 or len(ids) != len(set(ids)):
raise serializers.ValidationError(
"Не больше 100 критериев, без повторяющихся ID."
)
return rows


def criteria_data(program):
return {
"updated_at": program.datetime_updated,
"revision": revision(program),
"locked": locked(program),
# Historical scores have no timestamps; do not present the backfill date as first score.
"supported_types": SUPPORTED_TYPES,
"criteria": definitions(program),
}


@transaction.atomic
def save_criteria(program_id, user, payload):
program = managed_program(program_id, user, lock=True)
if locked(program):
raise CriteriaConflict(
"criteria_locked",
"В программе уже сохранена первая оценка. Критерии больше нельзя менять.",
)
serializer = CriteriaWrite(data=payload)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if data["expected_updated_at"] != program.datetime_updated or data[
"expected_revision"
] != revision(program):
raise CriteriaConflict(
"stale_criteria", "Настройки изменены. Обновите действующие критерии."
)
existing = {c.pk: c for c in program.criterias.all()}
keep = []
for row in data["criteria"]:
pk = row.pop("id", None)
old = existing.get(pk)
if pk is not None and old is None:
raise serializers.ValidationError(
{"criteria": "Критерий не принадлежит этой программе."}
)
unchanged = old and all(getattr(old, key) == value for key, value in row.items())
if not unchanged:
if row["type"] not in SUPPORTED_TYPES:
raise serializers.ValidationError(
{"criteria": "Дробный критерий можно только сохранить без изменений."}
)
low, high = row["min_value"], row["max_value"]
if row["type"] == "int":
if (
any(
v is None or not math.isfinite(v) or not v.is_integer()
for v in (low, high)
)
or low > high
):
raise serializers.ValidationError(
{
"criteria": "Укажите целые минимум и максимум; максимум не меньше минимума."
}
)
elif low is not None or high is not None:
raise serializers.ValidationError(
{"criteria": "Шкала доступна только для баллов."}
)
if old:
Criteria.objects.filter(pk=pk).update(**row)
keep.append(pk)
else:
keep.append(Criteria.objects.create(partner_program=program, **row).pk)
program.criterias.exclude(pk__in=keep).delete()
program.evaluation_criteria_version += 1
program.datetime_updated = timezone.now()
PartnerProgram.objects.filter(pk=program.pk).update(
evaluation_criteria_version=program.evaluation_criteria_version,
datetime_updated=program.datetime_updated,
)
return criteria_data(program)
Loading
Loading