Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/contracts/program-solutions-v1/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<!-- @format -->

Изменение 1.22.0: optional weight_percent у критериев, scoring_mode у результатов и contributions у строки решения. Режим weighted_percent нормализует каждый числовой критерий по его шкале и процентному весу, максимум равен 100; legacy сохраняет прежнюю сумму. Старые payload оценок, required поля, URL и примеры совместимы. Веса и режим входят в revision и фиксируются первой оценкой; правила Project и ProgramSolution используют общую формулу. Для внедрения нужны добавочные миграции программы/критерия и backend до Angular.

# C00 Контракт решения в программе

Изменение 1.21.0: публичные GET охватывают уже сданные конкурсные решения КЧ/хакатона после окончания программы и установки marker существующим publisher. Неконкурсные сохраненные материалы остаются без сдачи. DTO/156 schemas/59 operations сохраняются;6 новых независимых сценариев, итого305. Временный сценарий published_solution_list_competitive_pending_rule намеренно меняется404→200 с пустой страницей: весь конкурсный список больше не закрыт из-за отдельного вопроса о draft. Остальные298 старых сценариев структурно неизменны. Несданные конкурсные работы и архивы не выдаются, окончательная eligibility draft все еще ожидает ответа. Нового Angular UI, схемы БД или Project нет.
Expand Down
389 changes: 388 additions & 1 deletion docs/contracts/program-solutions-v1/examples.json

Large diffs are not rendered by default.

33 changes: 32 additions & 1 deletion docs/contracts/program-solutions-v1/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "PROCOLLAB Program Solutions C00",
"version": "1.21.0",
"version": "1.22.0",
"description": "Контракт решений в программах для реализации и моков BE01–BE09. API base URL задается окружением. snake_case на проводе. Старые API не меняются."
},
"servers": [
Expand Down Expand Up @@ -11549,6 +11549,15 @@
],
"maxLength": 1000,
"description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно."
},
"weight_percent": {
"type": [
"number",
"null"
],
"exclusiveMinimum": 0,
"maximum": 100,
"description": "Процентный вес числового критерия; null, если вес не применяется."
}
},
"required": [
Expand Down Expand Up @@ -12279,6 +12288,15 @@
"number",
"null"
]
},
"weight_percent": {
"type": [
"number",
"null"
],
"exclusiveMinimum": 0,
"maximum": 100,
"description": "Процентный вес числового критерия; null, если вес не применяется."
}
},
"required": [
Expand Down Expand Up @@ -12396,6 +12414,12 @@
"items": {
"$ref": "#/components/schemas/ResultExpert"
}
},
"contributions": {
"type": "object",
"additionalProperties": {
"type": "number"
}
}
},
"required": [
Expand Down Expand Up @@ -12481,6 +12505,13 @@
"items": {
"$ref": "#/components/schemas/ResultCriterion"
}
},
"scoring_mode": {
"type": "string",
"enum": [
"legacy",
"weighted_percent"
]
}
},
"required": [
Expand Down
174 changes: 174 additions & 0 deletions docs/weighted-case-criteria.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from partner_programs.admin_registration import ProgramRegistrationAdminMixin
from partner_programs.admin_publication import ProgramPublicationAdminMixin
from partner_programs.admin_certificate_fonts import ProgramCertificateFontsAdminMixin
from partner_programs.admin_criteria import ProgramCriteriaAdminMixin
from partner_programs.program_dates import (
DATE_FIELDS,
program_date_errors,
Expand All @@ -54,6 +55,7 @@ class PartnerProgramFieldInline(admin.TabularInline):

@admin.register(PartnerProgram)
class PartnerProgramAdmin(
ProgramCriteriaAdminMixin,
ProgramPublicationAdminMixin,
ProgramRegistrationAdminMixin,
ProgramCertificateFontsAdminMixin,
Expand Down Expand Up @@ -121,7 +123,7 @@ class Meta:

autocomplete_fields = ("managers",)
date_hierarchy = "datetime_started"
readonly_fields = ("datetime_created", "datetime_updated")
readonly_fields = ("datetime_created", "datetime_updated", "evaluation_criteria_link")
fieldsets = (
(
None,
Expand All @@ -139,6 +141,7 @@ class Meta:
"publish_projects_after_finish",
"max_project_rates",
"is_distributed_evaluation",
"evaluation_criteria_link",
"draft",
(
"datetime_started",
Expand Down
158 changes: 158 additions & 0 deletions partner_programs/admin_criteria.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Атомарная настройка критериев и весов через общий organizer service."""

from decimal import Decimal

from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.forms import formset_factory
from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from rest_framework.exceptions import APIException

from partner_programs.admin_registration import error_text
from partner_programs.services.management import managed_program
from partner_programs.services.management_criteria import criteria_data, save_criteria
from project_rates.scoring import SCORING_MODES


class ScoringForm(forms.Form):
scoring_mode = forms.ChoiceField(label="Способ расчета", choices=SCORING_MODES)
expected_updated_at = forms.DateTimeField(widget=forms.HiddenInput)
expected_revision = forms.CharField(widget=forms.HiddenInput)


class CriterionForm(forms.Form):
id = forms.IntegerField(required=False, widget=forms.HiddenInput)
name = forms.CharField(label="Название", max_length=50)
description = forms.CharField(
label="Описание", required=False, widget=forms.Textarea(attrs={"rows": 2})
)
type = forms.ChoiceField(
label="Тип ответа",
choices=(
("int", "Целые баллы"),
("float", "Дробные баллы"),
("bool", "Да / нет"),
("str", "Текстовый комментарий"),
),
initial="int",
)
min_value = forms.FloatField(label="Минимум", required=False)
max_value = forms.FloatField(label="Максимум", required=False)
weight_percent = forms.DecimalField(
label="Вес, %",
required=False,
max_digits=5,
decimal_places=2,
min_value=Decimal("0.01"),
max_value=Decimal("100"),
)


CriteriaFormSet = formset_factory(
CriterionForm, extra=3, can_delete=True, max_num=100, validate_max=True
)


class ProgramCriteriaAdminMixin:
def get_urls(self):
return [
path(
"<int:program_id>/evaluation-criteria/",
self.admin_site.admin_view(self.evaluation_criteria_view),
name="partner_programs_partnerprogram_evaluation_criteria",
)
] + super().get_urls()

def evaluation_criteria_link(self, obj):
if not obj or not obj.pk:
return "Сначала сохраните программу."
url = reverse(
"admin:partner_programs_partnerprogram_evaluation_criteria", args=[obj.pk]
)
return format_html('<a href="{}">Критерии, веса и способ расчета</a>', url)

evaluation_criteria_link.short_description = "Оценивание"

def evaluation_criteria_view(self, request, program_id):
program = self.get_queryset(request).filter(pk=program_id).first()
if program is None:
raise Http404
if not self.has_view_or_change_permission(request, program):
raise PermissionDenied
program = managed_program(program_id, request.user)
snapshot = criteria_data(program)
initial = dict(
scoring_mode=snapshot["scoring_mode"],
expected_updated_at=snapshot["updated_at"],
expected_revision=snapshot["revision"],
)
form = ScoringForm(
request.POST if request.method == "POST" else None, initial=initial
)
rows = CriteriaFormSet(
request.POST if request.method == "POST" else None,
initial=snapshot["criteria"],
prefix="criteria",
)
status, error = 200, None
if request.method == "POST":
if not self.has_change_permission(request, program):
raise PermissionDenied
if form.is_valid() and rows.is_valid():
criteria = [
{
key: value
for key, value in row.cleaned_data.items()
if key != "DELETE"
}
for row in rows
if row.cleaned_data and not row.cleaned_data.get("DELETE")
]
for row in criteria:
if row["type"] not in ("int", "float"):
row["min_value"] = row["max_value"] = None
if row.get("id"):
original = next(
(c for c in snapshot["criteria"] if c["id"] == row["id"]),
None,
)
if (
original
and original["description"] is None
and row["description"] == ""
):
row["description"] = None
try:
save_criteria(
program_id,
request.user,
{**form.cleaned_data, "criteria": criteria},
)
except APIException as exception:
error, status = error_text(exception.detail), exception.status_code
else:
messages.success(request, "Критерии и веса сохранены.")
return HttpResponseRedirect(request.path)
else:
status = 400
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"original": program,
"title": "Критерии и веса программы",
"scoring_form": form,
"criteria_formset": rows,
"locked": snapshot["locked"],
"can_change": self.has_change_permission(request, program),
"error": error,
}
return TemplateResponse(
request,
"partner_programs/admin/evaluation_criteria.html",
context,
status=status,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.18 on 2026-11-11 19:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("partner_programs", "0043_project_copy_form"),
]

operations = [
migrations.AddField(
model_name="partnerprogram",
name="evaluation_scoring_mode",
field=models.CharField(
choices=[
("legacy", "Сумма исходных баллов"),
("weighted_percent", "Веса, итог от 0 до 100"),
],
default="legacy",
db_default="legacy",
max_length=20,
verbose_name="Способ расчета оценки",
),
),
]
43 changes: 42 additions & 1 deletion partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ class PartnerProgram(models.Model):
)
evaluation_criteria_locked_at = models.DateTimeField(null=True, editable=False)
evaluation_criteria_version = models.PositiveIntegerField(default=0, editable=False)
evaluation_scoring_mode = models.CharField(
max_length=20,
default="legacy",
db_default="legacy",
choices=(
("legacy", "Сумма исходных баллов"),
("weighted_percent", "Веса, итог от 0 до 100"),
),
verbose_name="Способ расчета оценки",
)
data_schema = models.JSONField(
verbose_name="Схема данных в формате JSON",
help_text="Ключи - имена полей, значения - тип поля ввода",
Expand Down Expand Up @@ -301,6 +311,7 @@ class PartnerProgram(models.Model):
def clean(self):
super().clean()
self.validate_solution_template_change()
self.validate_scoring_mode_change()
from partner_programs.project_form import PROJECT_SECTIONS

if not isinstance(self.project_sections, dict) or any(
Expand Down Expand Up @@ -340,14 +351,44 @@ def validate_solution_template_change(self, previous=None):
{"solution_template": "После появления ответов шаблон нельзя менять."}
)

def validate_scoring_mode_change(self, previous=None):
from project_rates.criteria_policy import locked
from project_rates.scoring import LEGACY, validate_configuration

if not self.pk:
if self.evaluation_scoring_mode != LEGACY:
raise ValidationError(
{
"evaluation_scoring_mode": "Сначала создайте программу и настройте критерии."
}
)
return
if previous is None:
previous = type(self).objects.filter(pk=self.pk).first()
if previous and previous.evaluation_scoring_mode != self.evaluation_scoring_mode:
if locked(previous):
raise ValidationError(
{
"evaluation_scoring_mode": "После первой оценки способ расчета нельзя менять."
}
)
validate_configuration(self)

def save(self, *args, **kwargs):
fields = kwargs.get("update_fields")
if self.pk and (fields is None or "solution_template" in fields):
if not self.pk:
self.validate_scoring_mode_change()
if self.pk and (
fields is None
or {"solution_template", "evaluation_scoring_mode"}.intersection(fields)
):
with transaction.atomic():
previous = (
type(self).objects.select_for_update().filter(pk=self.pk).first()
)
self.validate_solution_template_change(previous)
if fields is None or "evaluation_scoring_mode" in fields:
self.validate_scoring_mode_change(previous)
# Обычный save устаревшей карточки не отменяет операторский откат.
# Служебный запуск меняется явным update_fields под program lock.
if previous and (
Expand Down
Loading
Loading