diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 6629b7e5..25cebdf6 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -1,5 +1,7 @@ +Изменение 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 нет. diff --git a/docs/contracts/program-solutions-v1/examples.json b/docs/contracts/program-solutions-v1/examples.json index e0d4745c..02eb5ff3 100644 --- a/docs/contracts/program-solutions-v1/examples.json +++ b/docs/contracts/program-solutions-v1/examples.json @@ -1,5 +1,5 @@ { - "contract_version": "1.21.0", + "contract_version": "1.22.0", "description": "Независимые примеры, не последовательность операций; фиктивные данные.", "scenarios": [ { @@ -12645,6 +12645,393 @@ "errors": [], "meta": {} } + }, + { + "name": "solution_results_weighted_percent", + "operation_id": "getSolutionResults", + "status": 200, + "response": { + "program_id": 12, + "program_name": "Кейс-чемпионат PROCOLLAB", + "generated_at": "2026-10-24T20:00:00Z", + "required_evaluators": 2, + "maximum": 100, + "has_numeric_criteria": true, + "case_options": [ + "Цифровой сервис" + ], + "groups": [ + { + "case_name": "Цифровой сервис", + "solutions": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "completed": 1, + "required": 2, + "evaluation_complete": false, + "ready": false, + "score": 54, + "place": null, + "averages": { + "21": 0, + "22": 4.5 + }, + "experts": [ + { + "user_id": 91, + "name": "Эксперт программы", + "email": "expert@local.example", + "avatar": null, + "complete": true, + "total": 54, + "values": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + } + ], + "contributions": { + "21": 0, + "22": 54 + } + } + ] + } + ], + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "weight_percent": null + } + ], + "scoring_mode": "weighted_percent" + } + }, + { + "name": "solution_rating_weighted_percent", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5", + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False", + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + "weight_percent": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } } ] } diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index 02f502a0..34f3b203 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -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": [ @@ -11549,6 +11549,15 @@ ], "maxLength": 1000, "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12279,6 +12288,15 @@ "number", "null" ] + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12396,6 +12414,12 @@ "items": { "$ref": "#/components/schemas/ResultExpert" } + }, + "contributions": { + "type": "object", + "additionalProperties": { + "type": "number" + } } }, "required": [ @@ -12481,6 +12505,13 @@ "items": { "$ref": "#/components/schemas/ResultCriterion" } + }, + "scoring_mode": { + "type": "string", + "enum": [ + "legacy", + "weighted_percent" + ] } }, "required": [ diff --git a/docs/weighted-case-criteria.md b/docs/weighted-case-criteria.md new file mode 100644 index 00000000..4ed034f1 --- /dev/null +++ b/docs/weighted-case-criteria.md @@ -0,0 +1,174 @@ +# Процентные веса критериев кейса + +Согласовано 11 ноября 2026: пользователь выбрал проценты с общей суммой 100% +и итог кейса 0–100. Эта задача не включает квиз и смешанный рейтинг квиза/кейса. + +## Исходное поведение и границы + +API base: `b4575a0770c0515a8099cd3920416a7cbcf953bf`, Angular base: +`936dd7e1043f07c4a25dcd819ae7d5523644724f`, ветки от dev. Работа выполняется +отдельно от white label PR и опубликованного пилота; серверы не изменяются. + +`Criteria` сейчас содержит тип и шкалу, без веса. Общая +`project_rates/evaluation_math.py:evaluation_totals` суммирует числовые критерии +полной оценки и усредняет суммы экспертов. Ее используют результаты Project и +ProgramSolution. Результаты и XLSX используют общий snapshot, но XLSX также +содержит собственные формулы SUM/AVERAGEIF. Число полных оценок для допуска в +рейтинг, места при равенстве, сроки и object permissions уже определены кодом. +`criteria_policy` фиксирует критерии после первой сохраненной оценки. + +Организатор редактирует критерии в существующем разделе управления и Django +admin. Эксперт выставляет исходные баллы; участник не задает веса. Права остаются +на сервере и ограничены той же программой. Angular получает веса из API. + +## Правила расчета + +- Существующие программы сохраняют режим `legacy`: результаты не пересчитываются. +- Режим `weighted_percent` выбирается организатором до первой сохраненной оценки. +- Каждый числовой критерий имеет вес больше 0 и не больше 100%, до двух знаков + после запятой. Общая сумма числовых весов должна быть ровно 100%. +- Шкала каждого взвешенного критерия конечна, максимум строго больше минимума. + Нормализованный вклад: `(оценка − минимум) / (максимум − минимум) × вес`. +- Минимум шкалы дает нулевой вклад, максимум — полный вес. Например, 8 из 10 + при шкале 0–10 и весе 40% дают 32 балла из 40. +- Комментарии и «да/нет» не имеют числового веса. Их прежние правила заполнения + и завершенности не меняются. +- Итог эксперта — сумма вкладов. Итог команды — среднее полных оценок экспертов. + Все эксперты равноправны; неполная оценка не превращается в ноль. +- Расчет выполняется Decimal без промежуточного округления вкладов. Финальный + балл округляется до двух знаков HALF_UP; действующие правила мест сохраняются. +- Сумма весов, шкалы, режим и веса блокируются после первой сохраненной оценки, + включая частичную. Проверка и сохранение используют общий program lock. +- В XLSX исходные баллы, веса и итог должны воспроизводить расчет API. + +## Контракт и совместимость + +В `GET/PUT /programs/:id/management/criteria/` добавляются `scoring_mode` +(`legacy|weighted_percent`) и nullable `weight_percent` у критерия. Старый PUT +без новых полей сохраняет текущий режим и веса существующих критериев. В новом +режиме сохранение неполного/невалидного набора возвращает 400 атомарно. +Существующие 401/403/404 и 409 `stale_criteria|criteria_locked` сохраняются. +Режим и веса входят в revision: устаревшая экспертная форма не пишет оценки +по измененным правилам. Payload исходных экспертных баллов сохраняется. + +Результаты Project и ProgramSolution добавляют режим, веса и вклады, сохраняя +исходные averages. Для нового режима maximum=100. Общая формула используется +обоими контурами; сущности и их права не объединяются. Отдельный React +Submission/Evaluation API не меняется. + +Нужны две добавочные миграции: режим в PartnerProgram с default legacy и nullable +Decimal-вес в Criteria. Старые записи остаются без весов. Backend выкладывается +перед Angular; миграции и установка на pilot выполняются отдельным контролируемым +этапом после verification. Автоматического изменения dev/prod нет. + +## UI и админка + +Расширяется существующая форма критериев: выбор способа расчета, поле «Вес, %» +у числового критерия и сумма весов с ошибкой при отклонении от 100%. Строка +критерия и форма эксперта показывают вес, результаты — формулу и вклад. Сохраняются +текущие карточки, модальное окно, Mont, controls и responsive layout; нового +экрана или изменения структуры навигации нет. Это ограниченное расширение +существующих полей, без отдельного нового визуального дизайна. + +В Django admin требуется эквивалентное атомарное редактирование всего набора +через тот же service, а не обход проверки суммы отдельными записями. + +## Acceptance criteria + +- W01: legacy программа с прежними оценками сохраняет прежние баллы, максимум, + места и XLSX. Проверка регрессионными тестами обеих моделей результатов. +- W02: manager своей программы сохраняет веса 40/35/25 и режим weighted_percent; + повторный GET возвращает те же числа. Проверка API и БД PostgreSQL. +- W03: неверная сумма, ноль/отрицательное/NaN/Infinity, больше двух знаков, + нечисловой взвешенный критерий и вырожденная шкала отклоняются без частичных + изменений. Проверка API/model/admin tests. +- W04: чужая программа/роль/гость не могут изменить веса; старый revision и первая + сохраненная оценка дают прежний 409. Проверка прямых HTTP-запросов и гонки. +- W05: на разных шкалах вклад нормализуется, максимум=100, минимум=0; неполные + оценки не входят в среднее, эксперты равноправны. Проверка Decimal примерами. +- W06: после первой оценки режим/веса нельзя изменить через API, model и admin; + экспертный POST проверяет актуальный revision. Проверка PostgreSQL tests. +- W07: Project и ProgramSolution с одинаковыми данными получают одинаковые + итоги, вклады и места; экспортные формулы воспроизводят результат. +- W08: Angular позволяет настроить веса, показывает их сумму и ошибки, сохраняет + черновик при 409 и блокирует форму после фиксации. Старый API без новых ключей + работает как legacy. Проверка component/adapter tests. +- W09: эксперт видит вес без ручного умножения; результаты показывают корректную + формулу/максимум. Проверка тестами и браузером desktop/mobile, клавиатурой. +- W10: миграции применяются к отдельной тестовой PostgreSQL; проверки/build и + итоговый review подтверждены до PR. Реальные серверы не затрагиваются. + +## Порядок реализации + +Backend-first: models/migrations → общий расчет и policy → атомарная настройка +API/admin → результаты/XLSX → API/locking/regression tests. Затем Angular +domain/adapter → существующие формы/результаты → tests/build/browser → review +обоих PR. По завершении фиксируются фактические проверки и ограничения. + +## Проверка реализации 11 ноября 2026 + +Checked by source: общий расчет используется результатами Project и ProgramSolution, +а API организатора и пакетный редактор Django admin вызывают один service. Права +организатора прежние: manager своей программы или staff; Django admin дополнительно +проверяет модельные view/change permissions. Веса и режим входят в revision. +Удаление ограничено проверенными ID; после program lock повторно проверяется +принадлежность изменяемого критерия и критерия сохраняемой оценки. + +Checked by tests: на отдельном PostgreSQL прошли 235 тестов `project_rates`, +`partner_programs.tests.test_management_criteria_api`, `test_management_results_api`, +`test_management_api`. Включены миграция туда/обратно с прежними оценками, +паритет Project/ProgramSolution, гонка первой оценки и изменения весов, удаление +замененного набора и перенос критерия до program lock. Негативные роли отдельно +перепроверены прямыми HTTP-запросами. Black и Python lint E/F/W прошли; +`makemigrations --check --dry-run` не обнаружил изменений. + +Checked by build: Angular production build и 57 тестов в семи файлах прошли, +включая adapters, общий expert form, обе модели результатов, criteria editor и +solution evaluation contract. C00 1.22.0: 156 schemas, 59 operations, 307 scenarios; +валидатор OpenAPI и TypeScript contract check прошли. Добавления схем и примеров +совпадают в двух repos; прежнее различие описаний published-solutions GET вне +этой задачи сохранено. Проверки форматирования и ESLint ограничены измененными +файлами; общерепозиторные автоисправления не запускались. + +Checked in browser: production Angular build с настоящим изолированным Django API, +синтетическими аккаунтами и заблокированными внешними сервисами. Организатор +сохранил 40/35/25 через форму; до распределения 100% сохранение недоступно. +Эксперт через форму сохранил 8/7/6: 71,5 балла. Второй эксперт отправил 10/8/8 +через обычный API: 88 баллов. Рейтинг показал 79,75 из 100 и первое место; +формула показала вклады 36 / 26,25 / 17,5. Первая оценка заблокировала настройки. +Desktop 1440 px и mobile 390 px проверены; форма и объяснение без горизонтального +скролла. Enter открывает объяснение, Escape закрывает; числовое поле preview +сохраняет доступное имя критерия. Ошибок страницы не было. Проверены текущие +Mont/components/layout; новых CSS, декоративных анимаций и UI-библиотек нет. + +Скачанный через API XLSX пересчитан LibreOffice: итоги экспертов 71,5 и 88, +рейтинг 79,75, веса 40/35/25. Это проверка вычисленных значений, а не только текста +формул. Тесты также проверяют метаданные, заморозку строки и Arial 10 нового листа. + +### Acceptance audit + +| AC | Evidence | Verdict | +| --- | ----------------------------------------------------------------------------------- | ------- | +| W01 | Legacy math/results/XLSX regression tests и migration roundtrip | PASS | +| W02 | PostgreSQL API roundtrip и сохранение 40/35/25 в браузере | PASS | +| W03 | Невалидные веса/шкалы, atomicity, model и admin tests | PASS | +| W04 | Guest/member/foreign manager/expert HTTP checks, stale revision и PostgreSQL race | PASS | +| W05 | Разные шкалы, границы 0/100, частичные оценки и Decimal examples | PASS | +| W06 | API/model/admin lock tests, повторная проверка родителя, locked UI | PASS | +| W07 | Реальные Project/ProgramSolution scores и schema validation; пересчет XLSX | PASS | +| W08 | Component/adapter tests: сумма, 409 draft, old API, float weight; browser save/lock | PASS | +| W09 | Expert form и results tests; browser desktop/mobile/keyboard | PASS | +| W10 | Изолированный PostgreSQL, migration roundtrip, build/checks и итоговый review | PASS | + +Итог review: actionable BLOCKER/MAJOR/MINOR findings не осталось. Найденные гонки +исправлены и проверены; основной flow, совместимость и границы роли подтверждены. + +Not checked: установка на pilot/dev/prod, работа реальной почты, приемка клиента +и нагрузка на 100 участников. Они не являются частью этой локальной проверки +весов. Квиз и смешивание квиза с кейсом не реализовывались. Логи, скриншоты и +синтетические данные QA остаются вне Git; секреты в PR не входят. + +Проверка установки на пилот выявила требование к откату: у режима также задан +`db_default="legacy"`. Исходный ORM без нового поля после миграции продолжает +создавать программы. Регрессия проверена исторической моделью в PostgreSQL и +реальным старым исходным кодом; добавочные колонки при откате не удаляются. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 77633333..0d35196d 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -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, @@ -54,6 +55,7 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin( + ProgramCriteriaAdminMixin, ProgramPublicationAdminMixin, ProgramRegistrationAdminMixin, ProgramCertificateFontsAdminMixin, @@ -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, @@ -139,6 +141,7 @@ class Meta: "publish_projects_after_finish", "max_project_rates", "is_distributed_evaluation", + "evaluation_criteria_link", "draft", ( "datetime_started", diff --git a/partner_programs/admin_criteria.py b/partner_programs/admin_criteria.py new file mode 100644 index 00000000..e7c40c5f --- /dev/null +++ b/partner_programs/admin_criteria.py @@ -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( + "/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('Критерии, веса и способ расчета', 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, + ) diff --git a/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py b/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py new file mode 100644 index 00000000..7d879308 --- /dev/null +++ b/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py @@ -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="Способ расчета оценки", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 19f1df93..2b8d6716 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -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="Ключи - имена полей, значения - тип поля ввода", @@ -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( @@ -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 ( diff --git a/partner_programs/services/management_criteria.py b/partner_programs/services/management_criteria.py index 387ecfa7..5d2708a9 100644 --- a/partner_programs/services/management_criteria.py +++ b/partner_programs/services/management_criteria.py @@ -1,6 +1,8 @@ """Atomic editing of the shared program criteria, before the first score.""" import math +from decimal import Decimal +from types import SimpleNamespace from django.db import transaction from django.utils import timezone @@ -11,6 +13,8 @@ from partner_programs.services.management import managed_program from project_rates.criteria_policy import CriteriaConflict, definitions, locked, revision from project_rates.models import Criteria +from project_rates.scoring import SCORING_MODES, validate_configuration +from django.core.exceptions import ValidationError as ModelValidationError SUPPORTED_TYPES = ("int", "bool", "str") @@ -24,12 +28,22 @@ class CriterionWrite(StrictFieldsMixin, serializers.Serializer): type = serializers.ChoiceField(choices=(*SUPPORTED_TYPES, "float")) min_value = serializers.FloatField(allow_null=True) max_value = serializers.FloatField(allow_null=True) + weight_percent = serializers.DecimalField( + max_digits=5, + decimal_places=2, + min_value=Decimal("0.01"), + max_value=Decimal("100"), + required=False, + allow_null=True, + coerce_to_string=False, + ) class CriteriaWrite(StrictFieldsMixin, serializers.Serializer): expected_updated_at = serializers.DateTimeField() expected_revision = serializers.CharField(max_length=64) criteria = CriterionWrite(many=True) + scoring_mode = serializers.ChoiceField(choices=SCORING_MODES, required=False) def validate_criteria(self, rows): ids = [r["id"] for r in rows if r.get("id") is not None] @@ -48,6 +62,7 @@ def criteria_data(program): # Historical scores have no timestamps; do not present the backfill date as first score. "supported_types": SUPPORTED_TYPES, "criteria": definitions(program), + "scoring_mode": program.evaluation_scoring_mode, } @@ -69,7 +84,28 @@ def save_criteria(program_id, user, payload): "stale_criteria", "Настройки изменены. Обновите действующие критерии." ) existing = {c.pk: c for c in program.criterias.all()} + mode = data.get("scoring_mode", program.evaluation_scoring_mode) + candidates = [] + for row in data["criteria"]: + old = existing.get(row.get("id")) + row.setdefault("weight_percent", old.weight_percent if old else None) + candidates.append(SimpleNamespace(**row)) + try: + from project_rates.scoring import validate_weight + + for candidate in candidates: + validate_weight(candidate) + validate_configuration(SimpleNamespace(evaluation_scoring_mode=mode), candidates) + except (ModelValidationError, ValueError) as error: + raise serializers.ValidationError( + {"criteria": getattr(error, "messages", [str(error)])} + ) from error + # Режим и весь набор видны другим транзакциям только после commit. Все записи + # оценок используют тот же program lock; частичное состояние недоступно. + PartnerProgram.objects.filter(pk=program.pk).update(evaluation_scoring_mode=mode) + program.evaluation_scoring_mode = mode keep = [] + created = [] for row in data["criteria"]: pk = row.pop("id", None) old = existing.get(pk) @@ -77,7 +113,11 @@ def save_criteria(program_id, user, payload): raise serializers.ValidationError( {"criteria": "Критерий не принадлежит этой программе."} ) - unchanged = old and all(getattr(old, key) == value for key, value in row.items()) + unchanged = old and all( + getattr(old, key) == value + for key, value in row.items() + if key != "weight_percent" + ) if not unchanged: if row["type"] not in SUPPORTED_TYPES: raise serializers.ValidationError( @@ -105,12 +145,18 @@ def save_criteria(program_id, user, payload): Criteria.objects.filter(pk=pk).update(**row) keep.append(pk) else: - keep.append(Criteria.objects.create(partner_program=program, **row).pk) + criterion = Criteria(partner_program=program, **row) + criterion.full_clean() + created.append(criterion) + Criteria.objects.bulk_create(created) + keep.extend(criterion.pk for criterion in created) program.criterias.exclude(pk__in=keep).delete() program.evaluation_criteria_version += 1 + program.evaluation_scoring_mode = mode program.datetime_updated = timezone.now() PartnerProgram.objects.filter(pk=program.pk).update( evaluation_criteria_version=program.evaluation_criteria_version, datetime_updated=program.datetime_updated, + evaluation_scoring_mode=mode, ) return criteria_data(program) diff --git a/partner_programs/services/results.py b/partner_programs/services/results.py index b1c06db2..0c16fb68 100644 --- a/partner_programs/services/results.py +++ b/partner_programs/services/results.py @@ -29,6 +29,9 @@ class ResultsQuery(serializers.Serializer): def results_data(program, query, *, project_link_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program, criteria) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] field = get_program_case_field(program) options = field.get_options_list() if field else [] @@ -53,7 +56,9 @@ def results_data(program, query, *, project_link_id=None): ).select_related("expert__user"): people[assignment.project_id][assignment.expert.user_id] = assignment.expert.user required = program.required_evaluators or program.max_project_rates or 1 - maximum = evaluation_totals(criteria, {})["maximum"] + maximum = evaluation_totals( + criteria, {}, scoring_mode=program.evaluation_scoring_mode + )["maximum"] buckets = {name: [] for name in [*options, None]} for link in links: totals = evaluation_totals( @@ -62,6 +67,7 @@ def results_data(program, query, *, project_link_id=None): user_id: values[(link.project_id, user_id)] for user_id in people[link.project_id] }, + scoring_mode=program.evaluation_scoring_mode, ) experts = [] for user_id, user in sorted(people[link.project_id].items()): @@ -99,6 +105,7 @@ def results_data(program, query, *, project_link_id=None): "score": numeric(score), "place": None, "averages": averages, + "contributions": totals["contributions"], "experts": experts, } ) @@ -137,6 +144,7 @@ def results_data(program, query, *, project_link_id=None): return { "program_id": program.pk, "program_name": program.name, + "scoring_mode": program.evaluation_scoring_mode, "generated_at": timezone.now(), "required_evaluators": required, "maximum": numeric(maximum), @@ -151,6 +159,9 @@ def results_data(program, query, *, project_link_id=None): "type": c.type, "min_value": c.min_value, "max_value": c.max_value, + "weight_percent": float(c.weight_percent) + if c.weight_percent is not None + else None, } for c in criteria ], diff --git a/partner_programs/services/results_excel.py b/partner_programs/services/results_excel.py index bacd8c20..28c39734 100644 --- a/partner_programs/services/results_excel.py +++ b/partner_programs/services/results_excel.py @@ -4,10 +4,11 @@ from math import isfinite from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill +from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter from openpyxl.workbook.properties import CalcProperties from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE +from project_rates.scoring import WEIGHTED, decimal_number def text_cell(sheet, row, column, value): @@ -40,6 +41,15 @@ def _export_results(data, *, label, group_key, id_key): criteria = data["criteria"] numeric = [c for c in criteria if c["type"] in ("int", "float")] other = [c for c in criteria if c not in numeric] + weighted = data.get("scoring_mode") == WEIGHTED + definitions = None + if weighted: + definitions = workbook.create_sheet("Критерии") + definitions.append(["Критерий", "Минимум", "Максимум", "Вес, %"]) + for index, criterion in enumerate(numeric, 2): + text_cell(definitions, index, 1, criterion["name"]) + for col, key in enumerate(("min_value", "max_value", "weight_percent"), 2): + definitions.cell(index, col, float(decimal_number(criterion[key]))) rating.append( [ "Место", @@ -61,7 +71,7 @@ def _export_results(data, *, label, group_key, id_key): "Email", "Статус", *[c["name"] for c in numeric], - "Сумма", + "Итог из 100" if weighted else "Сумма", *[c["name"] for c in other], "ID решения" if id_key == "solution_id" else "ID проекта", "ID эксперта", @@ -99,10 +109,18 @@ def _export_results(data, *, label, group_key, id_key): if expert["complete"]: expert_rows.append(row) if numeric: + formula = f"=SUM(F{row}:{get_column_letter(total_col - 1)}{row})" + if weighted: + terms = [ + f"({get_column_letter(col)}{row}-'Критерии'!B{index})" + f"/('Критерии'!C{index}-'Критерии'!B{index})*'Критерии'!D{index}" + for index, col in enumerate(range(6, total_col), 2) + ] + formula = "=SUM(" + ",".join(terms) + ")" experts.cell( row, total_col, - f"=SUM(F{row}:{get_column_letter(total_col - 1)}{row})", + formula, ) for col, criterion in enumerate(other, total_col + 1): value = values.get(criterion["id"]) @@ -154,6 +172,15 @@ def _export_results(data, *, label, group_key, id_key): cell.fill = PatternFill("solid", fgColor="8A63E6") for col in range(1, sheet.max_column + 1): sheet.column_dimensions[get_column_letter(col)].width = 24 + if definitions: + definitions.freeze_panes = "A2" + definitions.auto_filter.ref = definitions.dimensions + for row in definitions: + for cell in row: + cell.font = Font(name="Arial", size=10, bold=cell.row == 1) + cell.alignment = Alignment(vertical="top", wrap_text=True) + for column, width in (("A", 42), ("B", 16), ("C", 16), ("D", 16)): + definitions.column_dimensions[column].width = width output = BytesIO() workbook.save(output) return output.getvalue() diff --git a/project_rates/admin.py b/project_rates/admin.py index 4b204cbc..ab0ef959 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -27,6 +27,21 @@ def clean(self): raise forms.ValidationError( "После первой оценки критерии программы зафиксированы." ) + if program: + from types import SimpleNamespace + from project_rates.scoring import validate_configuration + + candidate = SimpleNamespace( + **{ + key: data.get(key) + for key in ("type", "min_value", "max_value", "weight_percent") + } + ) + criteria = list(program.criterias.exclude(pk=self.instance.pk)) + [candidate] + try: + validate_configuration(program, criteria) + except DjangoValidationError as error: + raise forms.ValidationError(error.messages) from error return data @@ -34,7 +49,7 @@ def clean(self): @admin.register(Criteria) class CriteriaAdmin(admin.ModelAdmin): form = CriteriaAdminForm - list_display = ("id", "name", "type", "get_program_name") + list_display = ("id", "name", "type", "weight_percent", "get_program_name") list_display_links = ( "id", "name", diff --git a/project_rates/criteria_policy.py b/project_rates/criteria_policy.py index 95857bac..40f7621f 100644 --- a/project_rates/criteria_policy.py +++ b/project_rates/criteria_policy.py @@ -15,16 +15,33 @@ def __init__(self, code, detail): def definitions(program): - return list( + rows = list( program.criterias.order_by("pk").values( - "id", "name", "description", "type", "min_value", "max_value" + "id", + "name", + "description", + "type", + "min_value", + "max_value", + "weight_percent", ) ) + for row in rows: + if row["weight_percent"] is not None: + row["weight_percent"] = float(row["weight_percent"]) + return rows def revision(program): return hashlib.sha256( - json.dumps(definitions(program), ensure_ascii=False, sort_keys=True).encode() + json.dumps( + { + "scoring_mode": program.evaluation_scoring_mode, + "criteria": definitions(program), + }, + ensure_ascii=False, + sort_keys=True, + ).encode() ).hexdigest() @@ -46,3 +63,13 @@ def mark_locked(program): PartnerProgram.objects.filter(pk=program.pk).update( evaluation_criteria_locked_at=program.evaluation_criteria_locked_at ) + + +def require_scoring_configuration(program, criteria=None): + from django.core.exceptions import ValidationError + from project_rates.scoring import validate_configuration + + try: + validate_configuration(program, criteria) + except ValidationError as error: + raise CriteriaConflict("invalid_criteria", " ".join(error.messages)) from error diff --git a/project_rates/evaluation_math.py b/project_rates/evaluation_math.py index 8a9e5422..9aa9e120 100644 --- a/project_rates/evaluation_math.py +++ b/project_rates/evaluation_math.py @@ -3,6 +3,7 @@ from decimal import Decimal, ROUND_HALF_UP from project_rates.validators import ProjectScoreValidator +from project_rates.scoring import LEGACY, WEIGHTED, percentage_criteria, weighted_values def rounded(value): @@ -33,8 +34,48 @@ def complete_values(criteria, values): ) -def evaluation_totals(criteria, values_by_user): +def evaluation_totals(criteria, values_by_user, *, scoring_mode=LEGACY): """Сохраняет порядок округления существующего results_data, включая float totals.""" + if scoring_mode == WEIGHTED: + specifications = percentage_criteria(criteria) + evaluations, exact_totals, contributions = {}, {}, {} + for user_id, values in values_by_user.items(): + complete = complete_values(criteria, values) + parts = weighted_values(specifications, values) if complete else {} + total = sum(parts.values(), Decimal("0")) if complete else None + evaluations[user_id] = dict(complete=complete, total=numeric(total)) + if complete: + exact_totals[user_id] = total + contributions[user_id] = parts + completed = list(exact_totals) + return dict( + evaluations=evaluations, + completed=len(completed), + score=numeric(rounded(sum(exact_totals.values()) / len(completed))) + if completed + else None, + averages={ + str(c.pk): numeric( + rounded( + sum(Decimal(values_by_user[pk][c.pk]) for pk in completed) + / len(completed) + ) + ) + for c, *_ in specifications + if completed + }, + contributions={ + str(c.pk): numeric( + rounded( + sum(contributions[pk][c.pk] for pk in completed) / len(completed) + ) + ) + for c, *_ in specifications + if completed + }, + maximum=100.0, + has_numeric_criteria=True, + ) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] evaluations = {} for user_id, values in values_by_user.items(): @@ -76,6 +117,7 @@ def evaluation_totals(criteria, values_by_user): averages=averages, maximum=numeric(maximum), has_numeric_criteria=bool(numeric_criteria), + contributions={}, ) diff --git a/project_rates/migrations/0012_criteria_weight_percent_and_more.py b/project_rates/migrations/0012_criteria_weight_percent_and_more.py new file mode 100644 index 00000000..954bae2b --- /dev/null +++ b/project_rates/migrations/0012_criteria_weight_percent_and_more.py @@ -0,0 +1,40 @@ +# 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", "0044_partnerprogram_evaluation_scoring_mode"), + ("project_rates", "0011_solution_score_value_1000"), + ] + + operations = [ + migrations.AddField( + model_name="criteria", + name="weight_percent", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=5, + null=True, + verbose_name="Вес, %", + ), + ), + migrations.AddConstraint( + model_name="criteria", + constraint=models.CheckConstraint( + condition=models.Q( + ("weight_percent__isnull", True), + models.Q( + ("type__in", ("int", "float")), + ("weight_percent__gt", 0), + ("weight_percent__lte", 100), + ), + _connector="OR", + ), + name="criterion_weight_numeric_positive", + ), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 91686007..5bd7d824 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -13,17 +13,30 @@ class CriteriaQuerySet(models.QuerySet): def delete(self): from .criteria_policy import locked + from .scoring import validate_configuration with transaction.atomic(): - ids = self.values_list("partner_program_id", flat=True).distinct() - programs = ( + selected = list(self.values_list("pk", "partner_program_id")) + deleted = [pk for pk, _ in selected] + ids = {program_id for _, program_id in selected} + programs = list( PartnerProgram.objects.select_for_update() .filter(pk__in=ids) .order_by("pk") ) + current = list( + self.filter(pk__in=deleted).values_list("pk", "partner_program_id") + ) + if any(program_id not in ids for _, program_id in current): + raise ValidationError("Критерий изменился. Обновите данные.") + deleted = [pk for pk, _ in current] if any(locked(p) for p in programs): raise ValidationError("После первой оценки критерии нельзя удалять.") - return super().delete() + for program in programs: + validate_configuration( + program, list(program.criterias.exclude(pk__in=deleted)) + ) + return models.QuerySet.delete(self.filter(pk__in=deleted)) class Criteria(models.Model): @@ -56,6 +69,9 @@ class Criteria(models.Model): null=True, blank=True, ) + weight_percent = models.DecimalField( + verbose_name="Вес, %", max_digits=5, decimal_places=2, null=True, blank=True + ) partner_program = models.ForeignKey( PartnerProgram, on_delete=models.CASCADE, @@ -64,8 +80,23 @@ class Criteria(models.Model): objects = CriteriaQuerySet.as_manager() + def clean(self): + super().clean() + from .scoring import validate_weight + + try: + validate_weight(self) + except ValueError as error: + raise ValidationError({"weight_percent": str(error)}) from error + def save(self, *args, **kwargs): from .criteria_policy import locked + from .scoring import validate_weight + + try: + validate_weight(self) + except ValueError as error: + raise ValidationError({"weight_percent": str(error)}) from error with transaction.atomic(): old = type(self).objects.filter(pk=self.pk).first() if self.pk else None @@ -77,6 +108,12 @@ def save(self, *args, **kwargs): .filter(pk__in=ids) .order_by("pk") ) + current = type(self).objects.filter(pk=self.pk).first() if self.pk else None + if (old is not None and current is None) or ( + current is not None and current.partner_program_id not in ids + ): + raise ValidationError("Критерий изменился. Обновите данные.") + old = current changed = old is None or any( getattr(old, field) != getattr(self, field) for field in ( @@ -85,11 +122,20 @@ def save(self, *args, **kwargs): "type", "min_value", "max_value", + "weight_percent", "partner_program_id", ) ) if changed and any(locked(p) for p in programs): raise ValidationError("После первой оценки критерии нельзя менять.") + if changed: + from .scoring import validate_configuration + + for program in programs: + rows = list(program.criterias.exclude(pk=self.pk)) + if program.pk == self.partner_program_id: + rows.append(self) + validate_configuration(program, rows) return super().save(*args, **kwargs) def delete(self, *args, **kwargs): @@ -101,6 +147,16 @@ def __str__(self): class Meta: verbose_name = "Критерий оценки проекта" verbose_name_plural = "Критерии оценки проектов" + constraints = [ + models.CheckConstraint( + condition=models.Q(weight_percent__isnull=True) + | ( + models.Q(type__in=("int", "float")) + & models.Q(weight_percent__gt=0, weight_percent__lte=100) + ), + name="criterion_weight_numeric_positive", + ) + ] class ProjectScore(models.Model): @@ -137,7 +193,12 @@ def save(self, *args, **kwargs): ) with transaction.atomic(): program = PartnerProgram.objects.select_for_update().get(pk=program_id) + from .scoring import validate_configuration + + validate_configuration(program) self.criteria = Criteria.objects.get(pk=self.criteria_id) + if self.criteria.partner_program_id != program.pk: + raise ValidationError("Критерий изменился. Обновите форму оценки.") ProjectScoreValidator.validate( criteria_type=self.criteria.type, value=self.value, diff --git a/project_rates/scoring.py b/project_rates/scoring.py new file mode 100644 index 00000000..0be2af48 --- /dev/null +++ b/project_rates/scoring.py @@ -0,0 +1,73 @@ +"""Процентные веса и нормализация; один контракт для API, моделей и admin.""" + +from decimal import Decimal, InvalidOperation + +from django.core.exceptions import ValidationError + +LEGACY = "legacy" +WEIGHTED = "weighted_percent" +SCORING_MODES = ((LEGACY, "Сумма исходных баллов"), (WEIGHTED, "Веса, итог от 0 до 100")) +NUMERIC_TYPES = ("int", "float") + + +def decimal_number(value): + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ValueError("Укажите конечное число.") from error + if not result.is_finite(): + raise ValueError("Укажите конечное число.") + return result + + +def validate_weight(criterion): + value = getattr(criterion, "weight_percent", None) + if value is None: + return + weight = decimal_number(value) + if criterion.type not in NUMERIC_TYPES: + raise ValueError("Вес можно задать только числовому критерию.") + if not 0 < weight <= 100 or weight != weight.quantize(Decimal("0.01")): + raise ValueError( + "Укажите вес больше 0 и не больше 100%, до двух знаков после запятой." + ) + + +def percentage_criteria(criteria): + specifications = [] + for criterion in criteria: + validate_weight(criterion) + if criterion.type not in NUMERIC_TYPES: + continue + if getattr(criterion, "weight_percent", None) is None: + raise ValueError("Укажите вес каждого числового критерия.") + low = decimal_number(criterion.min_value) + high = decimal_number(criterion.max_value) + if high <= low: + raise ValueError("Для весов нужен конечный максимум строго больше минимума.") + specifications.append( + (criterion, low, high, decimal_number(criterion.weight_percent)) + ) + if not specifications or sum(row[3] for row in specifications) != Decimal("100"): + raise ValueError("Сумма весов числовых критериев должна быть ровно 100%.") + return specifications + + +def validate_configuration(program, criteria=None): + if program.evaluation_scoring_mode == LEGACY: + return + if program.evaluation_scoring_mode != WEIGHTED: + raise ValidationError({"evaluation_scoring_mode": "Выберите способ расчета."}) + try: + percentage_criteria( + list(program.criterias.all()) if criteria is None else criteria + ) + except ValueError as error: + raise ValidationError({"evaluation_scoring_mode": str(error)}) from error + + +def weighted_values(specifications, values): + return { + criterion.pk: (decimal_number(values[criterion.pk]) - low) / (high - low) * weight + for criterion, low, high, weight in specifications + } diff --git a/project_rates/serializers.py b/project_rates/serializers.py index d315339b..883e46c5 100644 --- a/project_rates/serializers.py +++ b/project_rates/serializers.py @@ -35,6 +35,16 @@ def validate(self, data): class CriteriaSerializer(serializers.ModelSerializer): + weight_percent = serializers.SerializerMethodField() + + def get_weight_percent(self, obj): + if ( + obj.partner_program.evaluation_scoring_mode != "weighted_percent" + or obj.weight_percent is None + ): + return None + return float(obj.weight_percent) + class Meta: model = Criteria exclude = ["partner_program"] @@ -105,7 +115,7 @@ def _get_program_scores(self, obj): program_id = self.context["view"].kwargs.get("program_id") return ProjectScore.objects.filter( project=obj, criteria__partner_program_id=program_id - ).select_related("criteria", "user") + ).select_related("criteria__partner_program", "user") def _get_user_scores(self, obj): scores = self._get_program_scores(obj) @@ -124,15 +134,17 @@ def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: **CriteriaSerializer(c).data, **({"value": scores[c.pk].value} if c.pk in scores else {}), } - for c in Criteria.objects.filter(partner_program_id=program_id).order_by( - "pk" - ) + for c in Criteria.objects.filter(partner_program_id=program_id) + .select_related("partner_program") + .order_by("pk") ] if user_scores: serializer = ProjectScoreSerializer(user_scores, many=True) return serializer.data program_id = self.context["view"].kwargs.get("program_id") - criterias = Criteria.objects.filter(partner_program__id=program_id) + criterias = Criteria.objects.filter( + partner_program__id=program_id + ).select_related("partner_program") serializer = CriteriaSerializer(criterias, many=True) return serializer.data diff --git a/project_rates/services.py b/project_rates/services.py index e77bff0d..17a3011c 100644 --- a/project_rates/services.py +++ b/project_rates/services.py @@ -74,7 +74,7 @@ def get_projects_for_rate_queryset( "scores", queryset=ProjectScore.objects.filter( criteria__partner_program=program - ).select_related("user"), + ).select_related("user", "criteria__partner_program"), to_attr="_program_scores", ) @@ -157,12 +157,17 @@ def submit_project_scores(*, user, project_id: int, data) -> None: program = PartnerProgram.objects.select_for_update().get(pk=programs.pop()) Expert.objects.get(user__id=user.id, programs=program) if ( - expected_revision is not None or program.evaluation_criteria_version + expected_revision is not None + or program.evaluation_criteria_version + or program.evaluation_scoring_mode == "weighted_percent" ) and expected_revision != revision(program): raise CriteriaConflict( "criteria_changed", "Критерии изменились. Обновите форму оценки; введенные ответы сохранены в форме.", ) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program) rating_data, criteria_ids, program = _prepare_project_score_data( user=user, project_id=project_id, diff --git a/project_rates/solution_evaluation.py b/project_rates/solution_evaluation.py index 8a348b7a..a4e50ea0 100644 --- a/project_rates/solution_evaluation.py +++ b/project_rates/solution_evaluation.py @@ -179,6 +179,10 @@ def rating_frame(solution, actor): type=c.type, min_value=c.min_value, max_value=c.max_value, + weight_percent=float(c.weight_percent) + if program.evaluation_scoring_mode == "weighted_percent" + and c.weight_percent is not None + else None, value=state["own"].get(c.pk), ) for c in criteria @@ -266,6 +270,9 @@ def rate_solution(*, program_id, solution_id, actor, expected_criteria_revision, "Критерии изменились. Обновите форму оценки.", meta={"current_criteria_revision": actual_revision}, ) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program) criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) definitions = {c.pk: c for c in criteria} state = metrics(program, [solution], actor, criteria)[solution.pk] diff --git a/project_rates/solution_models.py b/project_rates/solution_models.py index e6c77bd3..fd481ced 100644 --- a/project_rates/solution_models.py +++ b/project_rates/solution_models.py @@ -49,6 +49,9 @@ def clean(self): if solution and solution.status != "submitted": raise ValidationError({"solution": "Сначала нужно сдать решение."}) if criterion: + from project_rates.scoring import validate_configuration + + validate_configuration(criterion.partner_program) try: ProjectScoreValidator.validate( criteria_type=criterion.type, diff --git a/project_rates/solution_results.py b/project_rates/solution_results.py index 3d0c36db..1a8e5ca6 100644 --- a/project_rates/solution_results.py +++ b/project_rates/solution_results.py @@ -15,6 +15,9 @@ def results_data(program, query, *, solution_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program, criteria) field = get_program_case_field(program) options = field.get_options_list() if field else [] if query["case_scope"] == "selected" and query.get("case_name") not in options: @@ -40,6 +43,7 @@ def results_data(program, query, *, solution_id=None): totals = evaluation_totals( criteria, {user_id: values[(solution.pk, user_id)] for user_id in people[solution.pk]}, + scoring_mode=program.evaluation_scoring_mode, ) experts = [ dict( @@ -77,6 +81,7 @@ def results_data(program, query, *, solution_id=None): score=totals["score"], place=None, averages=totals["averages"], + contributions=totals["contributions"], experts=experts, ) ) @@ -108,10 +113,11 @@ def results_data(program, query, *, solution_id=None): ) if rows: groups.append(dict(case_name=case_name, solutions=rows)) - base = evaluation_totals(criteria, {}) + base = evaluation_totals(criteria, {}, scoring_mode=program.evaluation_scoring_mode) return dict( program_id=program.pk, program_name=program.name, + scoring_mode=program.evaluation_scoring_mode, generated_at=timezone.now(), required_evaluators=required, maximum=base["maximum"], @@ -126,6 +132,9 @@ def results_data(program, query, *, solution_id=None): type=c.type, min_value=c.min_value, max_value=c.max_value, + weight_percent=float(c.weight_percent) + if c.weight_percent is not None + else None, ) for c in criteria ], diff --git a/project_rates/tests/test_weighted_criteria.py b/project_rates/tests/test_weighted_criteria.py new file mode 100644 index 00000000..1fdc0527 --- /dev/null +++ b/project_rates/tests/test_weighted_criteria.py @@ -0,0 +1,535 @@ +from decimal import Decimal +from io import BytesIO +from types import SimpleNamespace + +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase, TestCase, TransactionTestCase +from django.urls import reverse +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.services.results_excel import export_results +from project_rates.evaluation_math import evaluation_totals +from project_rates.models import Criteria, ProjectScore +from project_rates.scoring import percentage_criteria +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_user, + create_rate_project, + link_project_to_program, +) + + +class WeightedMathTests(SimpleTestCase): + def criteria(self): + return [ + SimpleNamespace( + pk=i, + type="int", + min_value=0, + max_value=10, + weight_percent=Decimal(str(weight)), + ) + for i, weight in enumerate((40, 35, 25), 1) + ] + + def test_real_percentage_example_complete_experts_and_partial_excluded(self): + result = evaluation_totals( + self.criteria(), + {1: {1: "8", 2: "7", 3: "6"}, 2: {1: "10", 2: "8", 3: "8"}, 3: {1: "10"}}, + scoring_mode="weighted_percent", + ) + self.assertEqual(result["evaluations"][1]["total"], 71.5) + self.assertEqual(result["evaluations"][2]["total"], 88) + self.assertEqual(result["completed"], 2) + self.assertEqual(result["score"], 79.75) + self.assertEqual(result["maximum"], 100) + self.assertEqual(result["contributions"], {"1": 36.0, "2": 26.25, "3": 17.5}) + + def test_different_scales_minima_zero_and_maximum(self): + criteria = self.criteria()[:2] + criteria[0].min_value, criteria[0].max_value = 2, 6 + criteria[1].max_value, criteria[1].weight_percent = 100, Decimal("60") + for values, expected in [ + ({1: "2", 2: "0"}, 0), + ({1: "6", 2: "100"}, 100), + ({1: "4", 2: "50"}, 50), + ]: + with self.subTest(values=values): + self.assertEqual( + evaluation_totals( + criteria, {1: values}, scoring_mode="weighted_percent" + )["score"], + expected, + ) + + def test_non_numeric_values_still_determine_completeness_without_weight(self): + criteria = self.criteria() + [ + SimpleNamespace( + pk=4, type="str", min_value=None, max_value=None, weight_percent=None + ) + ] + result = evaluation_totals( + criteria, + {1: {1: "8", 2: "7", 3: "6"}, 2: {1: "8", 2: "7", 3: "6", 4: ""}}, + scoring_mode="weighted_percent", + ) + self.assertEqual(result["completed"], 1) + self.assertEqual(result["score"], 71.5) + + def test_invalid_configuration_cannot_calculate_a_plausible_ranking(self): + for change in [ + dict(weight_percent=None), + dict(weight_percent=0), + dict(weight_percent=Decimal("0.001")), + dict(max_value=0), + dict(max_value=float("inf")), + dict(weight_percent=Decimal("39.99")), + ]: + criteria = self.criteria() + for key, value in change.items(): + setattr(criteria[0], key, value) + with self.subTest(change=change), self.assertRaises(ValueError): + percentage_criteria(criteria) + + def test_legacy_keeps_raw_scales_even_if_unused_weights_are_present(self): + result = evaluation_totals(self.criteria(), {1: {1: "8", 2: "7", 3: "6"}}) + self.assertEqual(result["score"], 21) + self.assertEqual(result["maximum"], 30) + + +class WeightedCriteriaApiTests(TestCase): + def setUp(self): + self.program = create_rate_program() + self.manager = create_rate_user() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = f"/programs/{self.program.pk}/management/criteria/" + + def payload(self): + snapshot = self.client.get(self.url).data + return { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "scoring_mode": "weighted_percent", + "criteria": [ + dict( + name=f"Критерий {i}", + description="", + type="int", + min_value=0, + max_value=10, + weight_percent=weight, + ) + for i, weight in enumerate((40, 35, 25), 1) + ], + } + + def put(self, data): + return self.client.put(self.url, data, format="json") + + def configure(self): + result = self.put(self.payload()) + self.assertEqual(result.status_code, 200, result.data) + self.program.refresh_from_db() + return result.data + + def test_roundtrip_percentages_and_explicit_mode(self): + data = self.configure() + self.assertEqual(data["scoring_mode"], "weighted_percent") + self.assertEqual([c["weight_percent"] for c in data["criteria"]], [40, 35, 25]) + self.assertEqual(self.program.evaluation_scoring_mode, "weighted_percent") + + def test_invalid_numbers_ranges_sum_and_comment_weight_are_atomic(self): + baseline = list(self.program.criterias.values()) + for field, value in [ + ("weight_percent", 0), + ("weight_percent", -1), + ("weight_percent", 101), + ("weight_percent", "NaN"), + ("weight_percent", "Infinity"), + ("weight_percent", "40.001"), + ("weight_percent", 39.99), + ("max_value", 0), + ]: + payload = self.payload() + payload["criteria"][0][field] = value + with self.subTest(field=field, value=value): + result = self.put(payload) + self.assertEqual(result.status_code, 400, result.data) + self.assertEqual(list(self.program.criterias.values()), baseline) + payload = self.payload() + payload["criteria"][0].update(type="str", min_value=None, max_value=None) + self.assertEqual(self.put(payload).status_code, 400) + + def test_old_client_omissions_preserve_active_mode_and_weights(self): + data = self.configure() + for criterion in data["criteria"]: + criterion.pop("weight_percent") + result = self.put( + { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": data["criteria"], + } + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data["scoring_mode"], "weighted_percent") + self.assertEqual( + [c["weight_percent"] for c in result.data["criteria"]], [40, 35, 25] + ) + + def test_atomic_redistribution_add_remove_and_switch_back_to_legacy(self): + data = self.configure() + remaining = data["criteria"][:2] + remaining[0]["weight_percent"] = 40 + remaining[1]["weight_percent"] = 60 + payload = { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": remaining, + "scoring_mode": "weighted_percent", + } + response = self.put(payload) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.program.criterias.count(), 2) + data = response.data + payload.update( + expected_updated_at=data["updated_at"], + expected_revision=data["revision"], + scoring_mode="legacy", + criteria=[], + ) + response = self.put(payload) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.program.criterias.count(), 0) + + def test_model_and_individual_admin_cannot_make_active_weights_invalid(self): + self.configure() + criterion = self.program.criterias.order_by("pk").first() + criterion.weight_percent = Decimal("41") + with self.assertRaises(ValidationError): + criterion.save() + criterion.refresh_from_db() + with self.assertRaises(ValidationError): + criterion.delete() + from project_rates.admin import CriteriaAdminForm + + form = CriteriaAdminForm( + instance=criterion, + data={ + "partner_program": self.program.pk, + "name": criterion.name, + "description": "", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": "41", + }, + ) + self.assertFalse(form.is_valid()) + + def test_delete_does_not_remove_replacement_criteria_created_before_parent_lock(self): + from unittest.mock import patch + + from partner_programs.services.management_criteria import save_criteria + from project_rates.models import CriteriaQuerySet + + self.configure() + original = CriteriaQuerySet.values_list + replace = True + + def values_list(queryset, *fields, **kwargs): + nonlocal replace + rows = original(queryset, *fields, **kwargs) + if replace and fields == ("pk", "partner_program_id"): + selected = list(rows) + replace = False + payload = self.payload() + payload["criteria"] = [dict(payload["criteria"][0], weight_percent=100)] + save_criteria(self.program.pk, self.manager, payload) + return selected + return rows + + with patch.object(CriteriaQuerySet, "values_list", values_list): + deleted, _ = self.program.criterias.all().delete() + self.assertEqual(deleted, 0) + self.assertEqual(self.program.criterias.get().weight_percent, Decimal("100")) + + def test_score_rejects_criterion_moved_before_parent_lock(self): + from unittest.mock import patch + + from project_rates.models import CriteriaQuerySet + + target = create_rate_program() + criterion = self.program.criterias.get(type="str") + project = create_rate_project() + original = CriteriaQuerySet.get + move = True + + def get(queryset, *args, **kwargs): + nonlocal move + result = original(queryset, *args, **kwargs) + if move and queryset._fields == ("partner_program_id",): + move = False + criterion.partner_program = target + criterion.save() + return result + + with patch.object(CriteriaQuerySet, "get", get): + with self.assertRaisesMessage(ValidationError, "Критерий изменился"): + ProjectScore.objects.create( + criteria=criterion, project=project, user=self.manager, value="Оценка" + ) + criterion.refresh_from_db() + self.assertEqual(criterion.partner_program_id, target.pk) + self.assertFalse(ProjectScore.objects.filter(criteria=criterion).exists()) + + def test_expert_response_exposes_only_active_weights_and_stale_form_cannot_save(self): + from unittest.mock import patch + from project_rates.tests.helpers import create_rate_expert + + snapshot = self.configure() + expert = create_rate_expert(program=self.program) + project = create_rate_project() + link_project_to_program(self.program, project) + self.client.force_authenticate(expert) + response = self.client.get(f"/rate-project/{self.program.pk}") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + [c["weight_percent"] for c in response.data["results"][0]["criterias"]], + [40, 35, 25], + ) + self.client.force_authenticate(self.manager) + body = { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "criteria": snapshot["criteria"], + "scoring_mode": "legacy", + } + after = self.put(body) + self.assertEqual(after.status_code, 200, after.data) + self.assertNotEqual(after.data["revision"], snapshot["revision"]) + self.client.force_authenticate(expert) + values = [{"criterion_id": c["id"], "value": "8"} for c in snapshot["criteria"]] + with patch("project_rates.services.send_email.delay"): + response = self.client.post( + f"/rate-project/rate/{project.pk}", + {"expected_revision": snapshot["revision"], "scores": values}, + format="json", + ) + self.assertEqual(response.status_code, 409, response.data) + self.assertFalse(ProjectScore.objects.exists()) + response = self.client.get(f"/rate-project/{self.program.pk}") + self.assertTrue( + all( + c["weight_percent"] is None + for c in response.data["results"][0]["criterias"] + ) + ) + + def test_mode_and_weight_changes_change_revision_and_partial_score_freezes_them(self): + data = self.configure() + criterion = self.program.criterias.order_by("pk").first() + project = create_rate_project() + link_project_to_program(self.program, project) + ProjectScore.objects.create( + criteria=criterion, project=project, user=self.manager, value="8" + ) + payload = { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": data["criteria"], + "scoring_mode": "legacy", + } + self.assertEqual(self.put(payload).status_code, 409) + criterion.weight_percent = Decimal("50") + with self.assertRaises(ValidationError): + criterion.save() + self.program.evaluation_scoring_mode = "legacy" + with self.assertRaises(ValidationError): + self.program.save(update_fields=["evaluation_scoring_mode"]) + + def test_guest_foreign_manager_and_expert_cannot_set_weights(self): + from project_rates.tests.helpers import create_rate_expert + + payload = self.payload() + foreign_manager = create_rate_user() + create_rate_program().managers.add(foreign_manager) + expert = create_rate_expert(program=self.program) + for actor, expected in [ + (None, 401), + (create_rate_user(), 403), + (foreign_manager, 403), + (expert, 403), + ]: + self.client.force_authenticate(actor) + self.assertEqual(self.put(payload).status_code, expected) + + def test_admin_editor_uses_the_same_atomic_weight_validation(self): + self.manager.is_staff = self.manager.is_superuser = True + self.manager.save() + self.client.force_authenticate(user=None) + self.client.force_login(self.manager) + url = reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", + args=[self.program.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Сохранить критерии и веса") + from partner_programs.services.management_criteria import criteria_data + + snapshot = criteria_data(self.program) + fields = { + "scoring_mode": "weighted_percent", + "expected_updated_at": snapshot["updated_at"].isoformat(), + "expected_revision": snapshot["revision"], + "criteria-TOTAL_FORMS": "1", + "criteria-INITIAL_FORMS": "0", + "criteria-MIN_NUM_FORMS": "0", + "criteria-MAX_NUM_FORMS": "100", + "criteria-0-name": "Качество", + "criteria-0-type": "int", + "criteria-0-min_value": "0", + "criteria-0-max_value": "10", + "criteria-0-weight_percent": "99", + } + self.assertEqual(self.client.post(url, fields).status_code, 400) + self.assertEqual(self.program.criterias.count(), 1) + fields["criteria-0-weight_percent"] = "100" + self.assertEqual(self.client.post(url, fields).status_code, 302) + self.program.refresh_from_db() + self.assertEqual(self.program.evaluation_scoring_mode, "weighted_percent") + self.assertEqual(self.program.criterias.get().weight_percent, Decimal("100")) + + def test_admin_editor_requires_model_view_and_change_permissions(self): + from django.contrib.auth.models import Permission + + viewer = create_rate_user() + viewer.is_staff = True + viewer.save() + self.client.force_authenticate(user=None) + self.client.force_login(viewer) + url = reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", + args=[self.program.pk], + ) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual(self.client.post(url, {}).status_code, 403) + viewer.user_permissions.add( + Permission.objects.get(codename="view_partnerprogram") + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'value="Сохранить критерии и веса"') + self.assertEqual(self.client.post(url, {}).status_code, 403) + + def test_excel_exports_editable_weights_and_normalized_total_formula(self): + data = self.configure() + criteria = data["criteria"] + row = dict( + name="Команда", + case_name="Кейс", + completed=1, + required=1, + place=1, + ready=True, + evaluation_complete=True, + project_id=1, + experts=[ + dict( + name="Эксперт", + email="expert@example.test", + complete=True, + user_id=1, + values=[ + dict(criterion_id=c["id"], value=value) + for c, value in zip(criteria, ["8", "7", "6"]) + ], + ) + ], + ) + book = load_workbook( + BytesIO( + export_results( + dict( + scoring_mode="weighted_percent", + criteria=criteria, + groups=[dict(projects=[row])], + ) + ) + ) + ) + self.assertEqual(book.sheetnames, ["Рейтинг", "Оценки экспертов", "Критерии"]) + self.assertEqual(book["Критерии"]["D2"].value, 40) + self.assertEqual(book["Критерии"].freeze_panes, "A2") + self.assertEqual(book["Критерии"]["A1"].font.name, "Arial") + self.assertIn("'Критерии'!D2", book["Оценки экспертов"]["I2"].value) + self.assertIn("AVERAGEIF", book["Рейтинг"]["I2"].value) + + +class WeightedMigrationTests(TransactionTestCase): + def test_existing_scores_and_old_results_keep_their_legacy_mode(self): + from django.db import connection + from django.db.migrations.executor import MigrationExecutor + + program = create_rate_program() + criterion = Criteria.objects.create( + partner_program=program, + name="Качество", + type="int", + min_value=0, + max_value=10, + ) + project = create_rate_project() + user = create_rate_user() + score = ProjectScore.objects.create( + criteria=criterion, project=project, user=user, value="8" + ) + executor = MigrationExecutor(connection) + current = executor.loader.graph.leaf_nodes() + try: + executor.migrate( + [ + ("partner_programs", "0043_project_copy_form"), + ("project_rates", "0011_solution_score_value_1000"), + ] + ) + executor = MigrationExecutor(connection) + executor.migrate(current) + old_apps = executor.loader.project_state( + [ + ("partner_programs", "0043_project_copy_form"), + ("project_rates", "0011_solution_score_value_1000"), + ] + ).apps + old_program = old_apps.get_model("partner_programs", "PartnerProgram") + fields = { + field.attname: getattr(program, field.attname) + for field in old_program._meta.local_fields + if not field.primary_key + } + fields.update(name="Legacy writer", tag="legacy-writer-after-migration") + created = old_program.objects.create(**fields) + from partner_programs.models import PartnerProgram + + self.assertEqual( + PartnerProgram.objects.get(pk=created.pk).evaluation_scoring_mode, + "legacy", + ) + program.refresh_from_db() + criterion.refresh_from_db() + score.refresh_from_db() + self.assertEqual(program.evaluation_scoring_mode, "legacy") + self.assertIsNone(criterion.weight_percent) + self.assertEqual(score.value, "8") + self.assertEqual( + evaluation_totals([criterion], {user.pk: {criterion.pk: score.value}})[ + "score" + ], + 8, + ) + finally: + MigrationExecutor(connection).migrate(current) diff --git a/project_rates/tests/test_weighted_integration.py b/project_rates/tests/test_weighted_integration.py new file mode 100644 index 00000000..545043b4 --- /dev/null +++ b/project_rates/tests/test_weighted_integration.py @@ -0,0 +1,216 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import connections +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from jsonschema import Draft202012Validator + +from partner_programs.models import PartnerProgramProject +from partner_programs.services.management_criteria import criteria_data, save_criteria +from partner_programs.services.results import results_data as project_results +from project_rates.criteria_policy import CriteriaConflict +from project_rates.models import Criteria, CriteriaQuerySet, ProjectScore +from project_rates.services import submit_project_scores +from project_rates.solution_models import SolutionScore +from project_rates.solution_results import results_data as solution_results +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +def weighted_snapshot(program, manager, weights): + snapshot = criteria_data(program) + numbers = iter(weights) + for row in snapshot["criteria"]: + row["weight_percent"] = next(numbers) if row["type"] in ("int", "float") else None + result = save_criteria( + program.pk, + manager, + dict( + expected_updated_at=snapshot["updated_at"], + expected_revision=snapshot["revision"], + scoring_mode="weighted_percent", + criteria=snapshot["criteria"], + ), + ) + program.refresh_from_db() + return result + + +class WeightedResultsParityTests(EvaluationFixture, TestCase): + def test_actual_project_and_solution_scores_share_percentage_ranking_and_contract( + self, + ): + from pathlib import Path + import json + + self.seed_evaluation() + self.program.required_evaluators = 2 + self.program.save() + legacy = create_rate_program(is_competitive=True, required_evaluators=2) + legacy.managers.add(self.manager) + legacy.criterias.all().delete() + old_criteria = [ + Criteria.objects.create( + partner_program=legacy, + name=c.name, + type=c.type, + min_value=c.min_value, + max_value=c.max_value, + ) + for c in self.criteria + ] + weighted_snapshot(self.program, self.manager, [100]) + weighted_snapshot(legacy, self.manager, [100]) + project = create_rate_project(name=self.solution.name) + legacy.users.add(project.leader, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(legacy, project) + PartnerProgramProject.objects.filter(pk=link.pk).update(submitted=True) + for user, points in ((self.expert, "8"), (self.second, "6")): + for old, current, value in zip( + old_criteria, self.criteria, [points, "False", ""] + ): + ProjectScore.objects.create( + project=project, user=user, criteria=old, value=value + ) + SolutionScore.objects.create( + solution=self.solution, user=user, criteria=current, value=value + ) + query = dict(search="", case_scope="all", scope="all", order="desc", status="all") + before, after = project_results(legacy, query), solution_results( + self.program, query + ) + left, right = ( + before["groups"][0]["projects"][0], + after["groups"][0]["solutions"][0], + ) + self.assertEqual((left["score"], right["score"]), (70, 70)) + self.assertEqual((before["maximum"], after["maximum"]), (100, 100)) + self.assertEqual((left["place"], right["place"]), (1, 1)) + self.assertEqual( + list(left["contributions"].values()), list(right["contributions"].values()) + ) + self.assertEqual(list(left["averages"].values()), [7]) + self.assertEqual([e["total"] for e in left["experts"]], [80, 60]) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text() + ) + # DateTime JSON serialization follows the actual REST renderer. + from rest_framework.renderers import JSONRenderer + + Draft202012Validator( + { + "components": spec["components"], + "$ref": "#/components/schemas/SolutionResults", + } + ).validate(json.loads(JSONRenderer().render(after))) + + +class WeightedCriteriaRaceTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + @patch("project_rates.services.send_email.delay") + def test_percentage_change_and_first_partial_score_are_serialized(self, email): + program = create_rate_program() + manager = create_rate_user() + program.managers.add(manager) + expert = create_rate_expert(program=program) + project = create_rate_project() + link_project_to_program(program, project) + first = create_rate_criteria(program, min_value=0, max_value=10) + create_rate_criteria(program, min_value=0, max_value=10) + snapshot = weighted_snapshot(program, manager, [40, 60]) + payload = dict( + expected_updated_at=snapshot["updated_at"], + expected_revision=snapshot["revision"], + scoring_mode="weighted_percent", + criteria=snapshot["criteria"], + ) + for row in payload["criteria"]: + if row["type"] == "int": + row["weight_percent"] = 50 + barrier = Barrier(2) + + def run(action): + connections.close_all() + try: + barrier.wait(timeout=10) + if action == "manager": + save_criteria(program.pk, manager, payload) + else: + submit_project_scores( + user=expert, + project_id=project.pk, + data={ + "expected_revision": snapshot["revision"], + "scores": [{"criterion_id": first.pk, "value": "8"}], + }, + ) + return "ok" + except CriteriaConflict: + return "conflict" + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(run, ["manager", "expert"])) + self.assertCountEqual(results, ["ok", "conflict"]) + first.refresh_from_db() + self.assertEqual( + first.weight_percent, 40 if ProjectScore.objects.exists() else 50 + ) + + +class WeightedModelInterleavingTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_stale_criterion_cannot_move_away_from_a_newly_locked_program(self): + source = create_rate_program() + locked_program = create_rate_program() + target = create_rate_program() + manager = create_rate_user() + stale = source.criterias.get(type="str") + project = create_rate_project() + link_project_to_program(locked_program, project) + stale.partner_program = target + original = CriteriaQuerySet.first + move = True + + def move_and_score(): + connections.close_all() + try: + current = Criteria.objects.get(pk=stale.pk) + current.partner_program = locked_program + current.save() + ProjectScore.objects.create( + criteria=current, project=project, user=manager, value="Оценка" + ) + finally: + connections.close_all() + + def first(queryset): + nonlocal move + old = original(queryset) + if move and old is not None and old.pk == stale.pk: + move = False + # The competing transaction commits before the stale writer locks + # its originally observed parents; its writes must survive rejection. + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(move_and_score).result(timeout=10) + return old + + with patch.object(CriteriaQuerySet, "first", first): + with self.assertRaisesMessage(ValidationError, "Критерий изменился"): + stale.save() + stale.refresh_from_db() + self.assertEqual(stale.partner_program_id, locked_program.pk) + self.assertTrue(ProjectScore.objects.filter(criteria=stale).exists()) diff --git a/project_rates/typing.py b/project_rates/typing.py index 162e25af..70512376 100644 --- a/project_rates/typing.py +++ b/project_rates/typing.py @@ -8,6 +8,7 @@ class CriteriasResponse(TypedDict): type: str min_value: int | float | None max_value: int | float | None + weight_percent: float | None class ProjectScoresResponse(CriteriasResponse): diff --git a/templates/partner_programs/admin/evaluation_criteria.html b/templates/partner_programs/admin/evaluation_criteria.html new file mode 100644 index 00000000..a3f08deb --- /dev/null +++ b/templates/partner_programs/admin/evaluation_criteria.html @@ -0,0 +1,24 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

{{ original.name }}

+

Вес каждого числового критерия задается в процентах. В режиме весов сумма должна быть ровно 100%; итог кейса — от 0 до 100.

+

Минимум шкалы дает нулевой вклад, максимум — полный вес. Оценки экспертов учитываются одинаково.

+{% if locked %}

Первая оценка уже сохранена. Критерии, веса и способ расчета зафиксированы.

{% endif %} +{% if error %}{% endif %} +
{% csrf_token %} +{{ scoring_form.non_field_errors }} +{{ scoring_form.expected_updated_at }}{{ scoring_form.expected_revision }} +
+
{{ scoring_form.scoring_mode.errors }}{{ scoring_form.scoring_mode.label_tag }}{{ scoring_form.scoring_mode }}
+ {{ criteria_formset.management_form }}{{ criteria_formset.non_form_errors }} + {% for form in criteria_formset %} +
+

Критерий {{ forloop.counter }}

{{ form.non_field_errors }}{{ form.id }} + {% for field in form.visible_fields %}
{{ field.errors }}{{ field.label_tag }}{{ field }}
{% endfor %} +
+ {% endfor %} +
+{% if not locked and can_change %}
{% endif %} +
+

Вернуться к программе

+{% endblock %}