diff --git a/docs/modules/prod-program-analytics-project-cover.md b/docs/modules/prod-program-analytics-project-cover.md new file mode 100644 index 00000000..50474341 --- /dev/null +++ b/docs/modules/prod-program-analytics-project-cover.md @@ -0,0 +1,65 @@ +# PROD: выборочный перенос аналитики и стандартной обложки + +База: `4c49f5918e571708f872c532b1f5154fc2a03428`. +Ветка: `release/prod-program-analytics-project-cover`. + +Перенесены только DEV merge commits: + +- #742: `8ed5670d2123dd1f1b53668567ad60bde8641bcc`; +- #743: `dd3d48b121a1fd0668857c9b8766bb588be2360b`. + +## Различия с DEV и сохранение PROD + +Конфликт detail программы разрешён вручную: существующий +`current_project_application` сохранён с прежней семантикой лидера. +DEV-поле `current_application` не добавлено и публичный контракт не переименован. +Из #742 добавлен `is_user_expert`, необходимый для отображения секции эксперту +без member-роли. Это добавление вызвало описанное ниже падение строгого +регрессионного теста набора полей ответа. + +PROD уже использует `project_analytics` и `project_assignment_analytics`. +Новый сервис виджета вызывает существующие `_participant_metrics`, +`participants_without_team_rows`, `_annotated_solution_rows` и +`annotated_assignment_queryset`. Количество проектов и отправленных решений +считается SQL aggregate по тем же связям программы; большая аналитика не +сериализуется. Дополнительные DEV commits не переносились. + +Endpoint reset-cover использует существующие permissions ProjectDetail. +Глобальное удаление файлов, модели, миграции, правила оценки и сдачи, +React-контракты, зависимости, CI/Docker/workflows не менялись. + +## Локальные проверки 18.09.2026 + +Python 3.11, PostgreSQL 18 на localhost, изолированные временные БД. +Рабочие DEV/PROD БД не использовались. Настройки основаны на +`procollab.settings_ci`, включён NEXTGEN_SURFACE_ENABLED для регрессии контрактов. + +| Проверка | Результат | +| --- | --- | +| Targeted: widget, reset-cover, Project permissions, program detail/fields, legacy analytics, оценивание | 200 тестов, OK, 57,708 с | +| `python manage.py test --verbosity 1 --keepdb` | 1554 теста, failures=3, skipped=3, 811,056 с, exit 1 | +| `python manage.py check` | 0 issues | +| `python manage.py check --tag models` | 0 issues | +| `python manage.py makemigrations --check --dry-run` | No changes detected | +| Black изменённых Python-файлов | PASS | +| flake8 всех tracked Python-файлов | PASS | +| `git diff --check` | PASS | + +Все три падения полного прогона — subtests +`ProgramCurrentProjectApplicationTests.test_existing_production_detail_contract_is_preserved`: +анонимный пользователь, посторонний пользователь и участник-организатор. +Тест сравнивает точный набор полей detail с serializer и не ожидает добавленный +`is_user_expert: false`. Сам `current_project_application` исключён из этого +сравнения; его selector и семантика не менялись. Не следует трактовать это +падение как доказательство изменения прав или выбора проекта. + +Тест не ослаблялся, полный прогон не объявляется зелёным. По STOP-условию задачи +релиз остановлен до PR, snapshot, merge и deployment. Angular full suite также +завершился с exit 1 из-за необработанного teardown-таймера ngx-autosize. + +## Порядок будущего релиза + +После устранения блокеров и нового полного зелёного прогона нужны отдельные +PROD PR и свежий PROD snapshot. Backend развёртывается первым, Angular после +подтверждённой работоспособности новых API. Этот документ не подтверждает +развёртывание и не заменяет фактические runtime receipt и smoke. diff --git a/docs/modules/program-role-widget.md b/docs/modules/program-role-widget.md new file mode 100644 index 00000000..cf546b40 --- /dev/null +++ b/docs/modules/program-role-widget.md @@ -0,0 +1,99 @@ +# Read-only виджет программы для Angular + +PROD-port backend #742 основан на master `4c49f5918e571708f872c532b1f5154fc2a03428`. +Сохранены namespace `project-analytics` и поле `current_project_application`. +Отсутствующие на PROD DEV-имена helpers не добавляются как копии: виджет использует +существующие селекторы legacy Project; React manager-overview не участвует. +Результаты нового production-прогона фиксируются в PR; исторические DEV-проверки +ниже не заменяют проверку PROD-ветки. + +## Контракт + +`GET /programs/{id}/analytics-widget/`, обязательная аутентификация. +Параметры `role`, `user_id` и `project_id` не используются для авторизации или выбора проекта. +GET не изменяет бизнес-данные. Ответ содержит только ветвь разрешённой роли: + +```json +{ + "program_id": 12, + "role": "participant", + "is_competitive": true, + "participant": { + "participant_project": { "id": 5, "name": "StudyFlow", "program_link_id": 34 }, + "case_provided": true, + "case_name": "Цифровой сервис", + "stage": "review", + "submission_open": true + } +} +``` + +Пример иллюстрирует форму, значения не являются данными DEV. Остальные ветви: + +- `organizer`: `participants`, `projects`, `submitted_solutions`, `participants_without_project`. +- `expert`: `mode` (`distributed` / `open`), `assigned`, `remaining`, `evaluation_ends` (ISO datetime либо null). +- `participant_project` и `case_name` могут быть null. `stage`: `none`, `not_submitted`, `submitted`, `review`, `evaluated`, `not_applicable`. +- Неприменимые счётчики — null: open assigned/remaining; неконкурсные submitted_solutions и персональные distributed-счётчики. Реальный срок оценивания передаётся также для open. + +401 — нет входа; 403 — нет роли в этой программе; 404 — программа отсутствует; 409 — противоречивые связи команды, не выбор первого проекта. Angular не отображает raw response body и не подменяет ошибку нулями. + +## Источники и переиспользование + +| Показатель | Источник и правило | Права / изменение | +| --- | --- | --- | +| Роль | `program.is_manager(user)` → `program.experts` → `PartnerProgramUserProfile` | Только текущая программа; global staff/user_type, наличие назначений и клиентские claims не дают роль | +| Проект команды | PartnerProgramProject текущей программы; Project.leader либо Exists Collaborator | Новый локальный selector; максимум две строки для обнаружения несоответствия правилу одной команды. Один проект без выбора | +| Кейс | `get_program_case_field`: точный name="case"; PartnerProgramFieldValue текущей связи | Нет fallback по label, первому option, глобальному Project или другой программе | +| Участники и без проектов | `_participant_metrics` и `participants_without_team_rows` из `project_analytics` | Distinct user; без проекта = нет лидерства и Collaborator на проекте программы; черновик учитывается | +| Проекты и решения | SQL aggregate над `_annotated_solution_rows` из `project_analytics` | Единица учёта — PartnerProgramProject; отправленные — submitted-связи, не пользователи. Большая аналитика не сериализуется | +| Этап проекта | `_annotated_solution_rows` основной legacy-аналитики | Неподанный → not_submitted; submitted без начала → submitted; назначение/начатая оценка → review; общая итоговая классификация → evaluated | +| Остаток эксперта | `annotated_assignment_queryset`, фильтр текущих program/expert, SQL aggregate | Незавершённые назначения, включая not_ready, а не число незаполненных критериев | +| Срок | `PartnerProgram.datetime_evaluation_ends` | Не срок подачи или окончания программы | + +Distributed completion остаётся общим: submitted-связь, ненулевой набор критериев и заполнение всех критериев назначения; для проекта должны завершиться все назначения. Записанная допустимая оценка 0 считается заполненной. Неотправленный проект не завершён даже при существующих оценках. + +В open сохранено существующее правило основной аналитики: ProjectScore по критерию программы даёт проекту evaluated. Это не новая формула виджета. Назначения и персональный остаток в open не выдумываются; frontend сохраняет переход в оценивание и однократно показывает реальный срок. + +Дедлайн не участвует в определении оценённости. Баллы, личности экспертов и закрытые результаты участнику не сериализуются. Неконкурсная программа не получает искусственную обязательную сдачу. + +## Изоляция контрактов + +Используются только Angular-модели Project, Collaborator, PartnerProgramProject, PartnerProgramUserProfile, Expert, ProjectExpertAssignment, Criteria, ProjectScore и существующие поля программы. + +На PROD существующее поле `current_project_application` остаётся legacy-контрактом лидера с прежней семантикой. DEV-имя `current_application` не добавляется и не переименовывает PROD-контракт. Для члена команды null в этом поле не влияет на новый `participant_project`. Просмотр виджета не даёт права редактировать поля или отправлять проект вместо лидера. + +React-модели Application, Team, Submission, SubmissionExpertAssignment, Evaluation, их serializers/services/endpoints не используются и не изменяются. Менеджерские API не открываются другим ролям. + +В существующий program detail добавлен только контекстный boolean `is_user_expert`; он не раскрывает description/links незарегистрированному эксперту. Правила регистрации, выбора/сохранения case, сдачи, назначений, завершения и публикации сохранены. Нет миграций, зависимостей или изменений CI/Docker/workflows. + +Сервис использует общие SQL-агрегации. Не сериализует большую аналитику и не загружает проекты/регистрации в браузер. Тест проверяет постоянное число SQL-запросов при росте числа проектов и отсутствие INSERT/UPDATE/DELETE. + +## Исторические проверки DEV 14.09.2026 + +Python 3.11, существующее Poetry-окружение. Локальные SQLite и PostgreSQL 18; production/DEV базы не использовались. + +```sh +DEBUG=True python manage.py test partner_programs.tests.test_role_widget_api partner_programs.tests.test_program_detail partner_programs.tests.test_program_link_fields_api partner_programs.tests.test_manager_analytics_api partner_programs.tests.test_assignment_analytics_api project_rates.tests --noinput +``` + +Targeted: 120 тестов PASS. Новые 15 тестов проверяют роли и приоритет, эксперта без регистрации/назначений, чужую программу, manager-only API, лидера/члена команды, несколько программ с разными case/status, неизменность current_application и прав сдачи, integrity 409, точное поле case, distributed/open, 0/partial/full/not_ready, черновики и командную метрику, изоляцию назначений, реальный дедлайн, bounded SQL/read-only. + +Уникальность регистраций уже обеспечена схемой: тест проверяет отклонение дубликата и единственный учёт пользователя. Ограничения БД ради теста не менялись; общая аналитика сохраняет Count(distinct user). + +| Команда | Фактический результат | +| --- | --- | +| `DEBUG=True python manage.py test --noinput` (SQLite) | 849 тестов, OK (skipped=4), 570,439 с. После тестов exit 1: WinError 32 при удалении test_db.sqlite3 | +| `python manage.py test --noinput` с временным settings на основе `procollab.settings_ci` (PostgreSQL) | 849 тестов, 1 failure: `feed.tests.test_feed_api.FeedAPITests.test_feed_returns_project_news_as_news_content`, ожидалось news, получено project. Teardown также встретил 21 незакрытое соединение; после завершения процесса временная БД удалена | +| Тот же feed-тест отдельно на исходном `ed5244bd...`, PostgreSQL | PASS. Причина сбоя полного прогона не установлена; полный suite не объявляется успешным, feed-код не менялся | +| `python -m flake8` всех tracked Python и отдельно новых файлов | PASS | +| `python -m black --check` новых файлов и urls | PASS | +| `DEBUG=True python manage.py check` | 0 issues | +| `DEBUG=True python manage.py makemigrations --check --dry-run` | No changes detected | +| `python -m mypy partner_programs/services/role_analytics.py partner_programs/serializers/role_analytics.py partner_programs/widget_views.py` | Не выполнен typecheck: существующий mypy.ini объединяет две строки plugins в один import (`mypy_django_plugin.main\nmypy_drf_plugin.main`), exit 2. Конфигурация не менялась | +| `git diff --check` | PASS | + +Скриншоты трёх ролей/нулевых состояний и сравнение исходной геометрии находятся в парном Angular PR, `docs/program-role-widget/README.md`. Браузерный smoke использует реальные Angular-компоненты и локальные fixtures; живой DEV вход/сдача/оценивание в браузере не проверялись. + +## Зависимость развёртывания + +Сначала backend-контракт и detail-флаг, затем Angular-виджет. Изменение read-only, миграций нет. Фактическое состояние релиза и новый полный прогон PROD-ветки фиксируются отдельно в PR и отчёте развёртывания. diff --git a/docs/project-default-cover-reset.md b/docs/project-default-cover-reset.md new file mode 100644 index 00000000..3c85ab75 --- /dev/null +++ b/docs/project-default-cover-reset.md @@ -0,0 +1,127 @@ +# Возврат стандартной обложки legacy-проекта + +База DEV: `8ed5670d2123dd1f1b53668567ad60bde8641bcc`. +Задача относится к Angular и `Project`; модели и контракты React не изменены. + +## Причина + +Пользователь подтвердил на DEV попытку удалить стандартную обложку через +`DELETE /files/?link=...` и ответ 403: её `UserFile` принадлежит другому пользователю. +Второй конфликт — физическое удаление пользовательской обложки очищает обязательный +Angular-control. Глобальное удаление файлов и `Project.save()` не меняются. +На живом DEV автор этой правки удаление не выполнял. Сохранность общего файла и 403 +для чужого `UserFile` дополнительно проверены локальными API-тестами. + +## Контракт + +`POST /projects/{project_id}/reset-cover/`, тело `{}`. ID относится к `Project`, +а не `PartnerProgramProject`. + +Успех 200: + +```json +{ + "cover_image_address": "https://cdn.example/default.png", + "is_default_cover": true +} +``` + +Если доступных записей с непустой ссылкой `DefaultProjectCover.image` нет: + +```json +{ + "code": "default_cover_unavailable", + "detail": "Стандартная обложка временно недоступна." +} +``` + +Статус 409; проект и прежний файл сохраняются. Проверка доступности означает +наличие ссылки в БД: отдельный сетевой HEAD в CDN не выполняется. +Уже стандартная обложка возвращается без переназначения и удаления. +Сокращённые списки проектов сохраняют прежний контракт. + +`GET /projects/{id}/` и ответы изменения проекта содержат read-only +`is_default_cover`. Источник — точное совпадение URL с `DefaultProjectCover.image_id`. +Сериализатор загружает набор ссылок один раз на экземпляр, в том числе при `many=True`. +Это не глобальный кеш; после PUT проверяется фактическое значение, включая fallback +существующего `Project.save()`. + +## Права и порядок записи + +Команда использует непосредственно `ProjectDetail.permission_classes`: +`ProjectVisibilityPermission`, `HasInvolvementInProjectOrReadOnly`, +`TimingAfterEndsProgramPermission`. Object permissions проверяются на заблокированном +`select_for_update()` проекте внутри транзакции. + +- Лидер сохраняет прежние права. +- Collaborator/invite могут менять только доступный черновик по прежним правилам. +- Менеджер, эксперт или администратор не получают право записи только из-за роли. +- Сохраняется временной запрет редактирования после завершения программы. + +Сначала записываются новая обложка и время изменения проекта. Только после коммита +`on_commit` запускает best-effort очистку. При откате транзакции очистки нет. + +| Прежний файл | Поведение | +| --- | --- | +| Стандартная обложка | Идемпотентный ответ, без удаления | +| Собственный свободный `UserFile` | После коммита — CDN DELETE, затем удаление записи | +| Чужой `UserFile` | Сброс разрешён по правам проекта; файл сохраняется | +| Запись файла отсутствует | Сброс успешен, CDN не вызывается | +| Системный аватар или файл, используемый другим проектом | Очистка пропускается | +| CDN возвращает 404 | Очистка считается идемпотентной | +| CDN/network/БД очистки выбрасывает ошибку | Новая обложка сохраняется, ответ остаётся 200, запись файла остаётся при сбое CDN | + +Предупреждение очистки не содержит URL, пользователя, HTTP body или текста исключения. +Автоматическая очередь повторной очистки не добавлялась. Неудачная очистка может +оставить неиспользуемый файл; она не возвращает проекту удалённую обложку. +Общие права `/files/` и правила публикации/сдачи проекта не изменены. + +## Проверки + +Локально: Python 3.11, PostgreSQL, изолированная конфигурация на основе +`procollab.settings_ci`, тестовая БД `test_procollab_cover_reset_20260917`. +Зависимости, workflows и миграции не менялись. + +| Команда | Фактический результат | +| --- | --- | +| `python manage.py test projects.tests.test_project_cover_reset --verbosity 2 --keepdb` | 18/18, exit 0 | +| `python manage.py test projects.tests.test_project_cover_reset projects.tests.test_program_project_detail_access projects.tests.test_project_access --verbosity 1 --keepdb` | 38/38, exit 0 | +| `python manage.py test --verbosity 1 --keepdb` | 867 тестов, 1 failure в существующем `feed.tests.test_feed_api.FeedAPITests.test_feed_returns_project_news_as_news_content` | +| Та же полная команда на неизменённой базе | 849/849, exit 0 | +| 30 повторов упавшего теста на неизменённой базе | 2 failures с тем же `project != news`; нестабильность воспроизведена вне изменений | +| `python manage.py check` / `check --tag models` | Без замечаний | +| `python manage.py makemigrations --check --dry-run` | No changes detected | +| `python -m black --check procollab/settings_ci.py projects/cover_reset.py projects/views.py projects/urls.py projects/serializers.py projects/tests/test_project_cover_reset.py` | Успешно | +| `python -m flake8` для того же списка | Успешно | +| `git diff --check` | Успешно | + +Тесты проверяют матрицу прав сравнением с обычным PUT, сохранность собственных, +чужих и системных файлов, выполнение очистки после фиксации, отсутствие очистки +при rollback, корректный флаг и ровно один добавочный запрос для списка любой длины. +Результат штатного PostgreSQL CI GitHub фиксируется отдельно в Draft PR. + +## Реальная локальная интеграция с Angular + +Отдельная БД `procollab_cover_reset_20260917` создана из предыдущего синтетического +локального стенда. Никаких DEV/PROD данных. Работали настоящий Angular, Django, +JWT, `Project`, `UserFile`, связи программы и права доступа. Только внешний CDN +заменён локальным файловым транспортом; его сбой задавался явно. + +| Сценарий | Результат | +| --- | --- | +| A: стандартная обложка | В редакторе нет удаления/сброса; DELETE отсутствует | +| B: upload → save → reopen | POST файла 201, PUT реального проекта 200; GET возвращает пользовательский URL и false | +| C: reset → GET → reopen | POST 200; фактическая стандартная ссылка и true; прежний собственный файл удалён после смены ссылки | +| D: чужой файл при законном редактировании | POST 200; стандартная ссылка; `UserFile` другого владельца сохранён | +| E: нет DefaultProjectCover | POST 409; понятная ошибка UI; GET сохраняет прежний URL и false | +| F: отказ CDN после переключения | POST 200; GET возвращает стандартную ссылку и true; прежний файл сохранён, предупреждение записано | + +В браузерной цепочке A–F не было `DELETE /files/`, `/projects/NaN/`, `/0/`, `/null/` +и ответов 5xx. Кейс связи 73 с программой 21 сохранился при сохранении проекта 31. +Скриншоты и обезличенный сетевой протокол находятся в Angular Draft PR этой задачи. + +## Зависимость для будущего DEV-развёртывания + +Сначала нужен этот backend-контракт, затем Angular-изменение, использующее +`is_default_cover` и reset-cover. Без флага frontend не предлагает опасное удаление. +Сейчас подготовлены только Draft PR; merge и deploy не выполнялись. diff --git a/docs/readme.md b/docs/readme.md index 33f6225b..acb286f7 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -7,6 +7,7 @@ ## API - [Описание API](api.md) +- [Ролевой виджет программы для Angular](modules/program-role-widget.md) - [Swagger](https://api.procollab.ru/swagger) - [Redoc](https://api.procollab.ru/redoc) diff --git a/partner_programs/serializers/role_analytics.py b/partner_programs/serializers/role_analytics.py new file mode 100644 index 00000000..c8497453 --- /dev/null +++ b/partner_programs/serializers/role_analytics.py @@ -0,0 +1,49 @@ +"""Минимальный контракт виджета; отсутствующие метрики не превращаются в нули.""" + +from rest_framework import serializers + + +class WidgetProjectSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_blank=True) + program_link_id = serializers.IntegerField() + + +class ParticipantWidgetSerializer(serializers.Serializer): + participant_project = WidgetProjectSerializer(allow_null=True) + case_provided = serializers.BooleanField() + case_name = serializers.CharField(allow_null=True, allow_blank=True) + stage = serializers.ChoiceField( + choices=( + "none", + "not_submitted", + "submitted", + "review", + "evaluated", + "not_applicable", + ) + ) + submission_open = serializers.BooleanField() + + +class OrganizerWidgetSerializer(serializers.Serializer): + participants = serializers.IntegerField(min_value=0) + projects = serializers.IntegerField(min_value=0) + submitted_solutions = serializers.IntegerField(min_value=0, allow_null=True) + participants_without_project = serializers.IntegerField(min_value=0) + + +class ExpertWidgetSerializer(serializers.Serializer): + mode = serializers.ChoiceField(choices=("open", "distributed")) + assigned = serializers.IntegerField(min_value=0, allow_null=True) + remaining = serializers.IntegerField(min_value=0, allow_null=True) + evaluation_ends = serializers.DateTimeField(allow_null=True) + + +class ProgramRoleWidgetSerializer(serializers.Serializer): + program_id = serializers.IntegerField() + role = serializers.ChoiceField(choices=("organizer", "expert", "participant")) + is_competitive = serializers.BooleanField() + participant = ParticipantWidgetSerializer(required=False) + organizer = OrganizerWidgetSerializer(required=False) + expert = ExpertWidgetSerializer(required=False) diff --git a/partner_programs/services/role_analytics.py b/partner_programs/services/role_analytics.py new file mode 100644 index 00000000..c4d6c740 --- /dev/null +++ b/partner_programs/services/role_analytics.py @@ -0,0 +1,163 @@ +"""Компактная аналитика текущего пользователя без публикации чужих результатов.""" + +from django.db.models import Count, Exists, OuterRef, Q +from rest_framework.exceptions import APIException, PermissionDenied + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.project_analytics import ( + _annotated_solution_rows, + _participant_metrics, + participants_without_team_rows, +) +from partner_programs.services.project_assignment_analytics import ( + annotated_assignment_queryset, +) +from partner_programs.services.case_fields import get_program_case_field +from project_rates.models import ProjectScore +from projects.models import Collaborator + + +def program_widget_role(program, user): + """Роль только в этой программе; назначения и глобальный user_type не участвуют.""" + if not user or not user.is_authenticated: + return None + if program.is_manager(user): + return "organizer" + if program.experts.filter(user_id=user.pk).exists(): + return "expert" + if PartnerProgramUserProfile.objects.filter( + partner_program_id=program.pk, user_id=user.pk + ).exists(): + return "participant" + return None + + +def organizer_widget_metrics(program): + """Счётчики legacy project-analytics без загрузки проектов и назначений. + + На PROD общий селектор уже выделен из React manager-overview. Считаем + связи программы, а отсутствие команды берём из его списка внимания: + он учитывает и лидеров, и Collaborator, включая связанные черновики. + """ + participants = _participant_metrics(program.pk) + solutions = _annotated_solution_rows(program).aggregate( + created=Count("pk"), submitted=Count("pk", filter=Q(submitted=True)) + ) + return { + "participants": participants["unique_participants"], + "projects": solutions["created"], + "submitted_solutions": solutions["submitted"] if program.is_competitive else None, + "participants_without_project": participants_without_team_rows( + program.pk + ).count(), + } + + +def expert_widget_metrics(program, user): + """Остаток по назначениям, включая not_ready; нулевая оценка уже учтена общим SQL.""" + mode = "distributed" if program.is_distributed_evaluation else "open" + if mode == "open" or not program.is_competitive: + return {"mode": mode, "assigned": None, "remaining": None} + counts = ( + annotated_assignment_queryset(program.pk) + .filter(expert__user_id=user.pk) + .aggregate( + assigned=Count("pk"), + remaining=Count("pk", filter=Q(is_completed=False)), + ) + ) + return {"mode": mode, **counts} + + +def require_widget_role(program, user): + """Проверка GET-контракта не меняет регистрации, назначения или бизнес-данные.""" + role = program_widget_role(program, user) + if role is None: + raise PermissionDenied("Аналитика доступна только участникам этой программы.") + return role + + +def participant_project_links(program, user): + """Только лидер и действующий Collaborator; чужие программы и приглашения исключены.""" + team = Collaborator.objects.filter(project_id=OuterRef("project_id"), user_id=user.pk) + return ( + PartnerProgramProject.objects.filter(partner_program_id=program.pk) + .annotate(is_team_member=Exists(team)) + .filter(Q(project__leader_id=user.pk) | Q(is_team_member=True)) + ) + + +class ParticipantProjectIntegrityError(APIException): + status_code = 409 + default_detail = "Связи проекта команды требуют проверки организатором." + default_code = "participant_project_integrity_error" + + +def participant_widget_metrics(program, user): + """Единственный проект команды в программе, независимо от legacy current_application. + + Читаем максимум две связи для обнаружения нарушения правила одной команды. + Не выбираем первую запись и не меняем данные. Возвращаем только стадию + общей аналитики, без оценок, экспертов или неопубликованных результатов. + """ + links = participant_project_links(program, user) + link_ids = list(links.order_by("pk").values_list("pk", flat=True)[:2]) + if len(link_ids) > 1: + raise ParticipantProjectIntegrityError() + link_id = link_ids[0] if link_ids else None + + case_field = get_program_case_field(program) + result = { + "participant_project": None, + "case_provided": case_field is not None, + "case_name": None, + "stage": "not_applicable" if not program.is_competitive else "none", + "submission_open": program.is_project_submission_open(), + } + if link_id is None: + return result + link = _annotated_solution_rows(program).select_related("project").get(pk=link_id) + result["participant_project"] = { + "id": link.project_id, + "name": link.project.name, + "program_link_id": link.pk, + } + if case_field: + result["case_name"] = ( + link.field_values.filter(field=case_field) + .values_list("value_text", flat=True) + .first() + or None + ) + if program.is_competitive: + if not link.submitted: + result["stage"] = "not_submitted" + elif link.status == "evaluated": + result["stage"] = "evaluated" + elif (link.assignments_total or 0) > 0 or ProjectScore.objects.filter( + project_id=link.project_id, criteria__partner_program_id=program.pk + ).exists(): + result["stage"] = "review" + else: + result["stage"] = "submitted" + return result + + +def build_program_role_widget(program, user): + """Возвращает только разрешённую ветвь роли текущего пользователя в программе.""" + role = require_widget_role(program, user) + result = { + "program_id": program.pk, + "role": role, + "is_competitive": program.is_competitive, + } + if role == "organizer": + result["organizer"] = organizer_widget_metrics(program) + elif role == "expert": + result["expert"] = { + **expert_widget_metrics(program, user), + "evaluation_ends": program.datetime_evaluation_ends, + } + else: + result["participant"] = participant_widget_metrics(program, user) + return result diff --git a/partner_programs/tests/test_program_current_project_application.py b/partner_programs/tests/test_program_current_project_application.py index 2b01c75d..33bbef8c 100644 --- a/partner_programs/tests/test_program_current_project_application.py +++ b/partner_programs/tests/test_program_current_project_application.py @@ -274,7 +274,7 @@ def test_existing_production_detail_contract_is_preserved(self): for key, value in response.data.items() if key != "current_project_application" }, - {**expected, "is_user_member": is_member}, + {**expected, "is_user_member": is_member, "is_user_expert": False}, ) self.assertIs( "application_policy" in response.data, diff --git a/partner_programs/tests/test_role_widget_api.py b/partner_programs/tests/test_role_widget_api.py new file mode 100644 index 00000000..65b6ca4a --- /dev/null +++ b/partner_programs/tests/test_role_widget_api.py @@ -0,0 +1,310 @@ +"""Контракт Angular-виджета: права, общие расчёты и отсутствие побочных записей.""" + +from django.db import connection, IntegrityError, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ProgramRoleWidgetTests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_partner_program( + is_competitive=True, is_distributed_evaluation=True, max_project_rates=3 + ) + self.member = create_user() + create_program_member(self.program, user=self.member) + self.url = reverse( + "partner_programs:analytics-widget", kwargs={"pk": self.program.pk} + ) + self.client.force_authenticate(self.member) + + def widget(self, user=None): + if user is not None: + self.client.force_authenticate(user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_program_roles_and_priority_without_assignments(self): + expert = create_rate_expert(program=self.program) + self.assertEqual(self.widget(expert)["role"], "expert") + self.assertEqual(self.widget()["expert"]["assigned"], 0) + create_program_member(self.program, user=expert) + self.assertEqual(self.widget()["role"], "expert") + self.program.managers.add(expert) + data = self.widget() + self.assertEqual(data["role"], "organizer") + self.assertNotIn("expert", data) + self.assertNotIn("participant", data) + + def test_outsider_other_program_staff_and_client_claims_do_not_grant_role(self): + other = create_partner_program() + for user in ( + create_user(), + create_rate_expert(program=other), + create_user(is_staff=True), + ): + self.client.force_authenticate(user) + response = self.client.get( + self.url, {"role": "organizer", "user_id": self.member.pk} + ) + self.assertEqual(response.status_code, 403) + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.url).status_code, 401) + + def test_member_and_expert_cannot_access_manager_api(self): + url = reverse( + "partner_programs:project-analytics", kwargs={"program_id": self.program.pk} + ) + for user in (self.member, create_rate_expert(program=self.program)): + self.client.force_authenticate(user) + self.assertEqual(self.client.get(url).status_code, 403) + + def test_no_project_and_case_not_configured(self): + data = self.widget()["participant"] + self.assertIsNone(data["participant_project"]) + self.assertEqual(data["stage"], "none") + self.assertFalse(data["case_provided"]) + + def test_leader_and_collaborator_see_identical_program_scoped_project(self): + teammate = create_user() + create_program_member(self.program, user=teammate) + project = create_project(leader=self.member) + Collaborator.objects.create(project=project, user=teammate, role="Participant") + link = create_program_project(self.program, project=project) + other = create_partner_program() + other_link = create_program_project(other, project=project, submitted=True) + for program, program_link, value in ( + (self.program, link, "Alpha"), + (other, other_link, "Beta"), + ): + field = create_program_field( + program, + name="case", + field_type="select", + is_required=True, + show_filter=True, + options=[value], + ) + PartnerProgramFieldValue.objects.create( + program_project=program_link, field=field, value_text=value + ) + leader_data = self.widget()["participant"] + team_data = self.widget(teammate)["participant"] + self.assertEqual(leader_data, team_data) + self.assertEqual(team_data["participant_project"]["program_link_id"], link.pk) + self.assertEqual(team_data["case_name"], "Alpha") + self.assertEqual(team_data["stage"], "not_submitted") + fields_url = f"/programs/partner-program-projects/{link.pk}/fields/" + self.assertEqual(self.client.get(fields_url).status_code, 200) + self.assertEqual(self.client.put(fields_url, [], format="json").status_code, 403) + submit_url = f"/programs/partner-program-projects/{link.pk}/submit/" + self.assertEqual(self.client.post(submit_url).status_code, 403) + self.assertEqual(self.client.get(f"/projects/{project.pk}/").status_code, 200) + # Существующий PROD-контракт заявки остаётся доступен только лидеру. + detail = self.client.get(f"/programs/{self.program.pk}/") + self.assertEqual(detail.status_code, 200) + self.assertNotIn("current_application", detail.data) + self.assertIsNone(detail.data["current_project_application"]) + self.client.force_authenticate(self.member) + detail = self.client.get(f"/programs/{self.program.pk}/") + self.assertEqual( + detail.data["current_project_application"]["program_link_id"], link.pk + ) + + def test_integrity_conflict_does_not_choose_first_project(self): + for _ in range(2): + create_program_project( + self.program, project=create_project(leader=self.member) + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 409) + self.assertNotIn("participant", response.data) + + def test_case_uses_exact_name_and_never_defaults_to_first_option(self): + create_program_project(self.program, project=create_project(leader=self.member)) + create_program_field(self.program, name="topic", label="Кейс") + self.assertFalse(self.widget()["participant"]["case_provided"]) + create_program_field( + self.program, + name="case", + label="Challenge", + field_type="select", + is_required=True, + show_filter=True, + options=["Alpha"], + ) + data = self.widget()["participant"] + self.assertTrue(data["case_provided"]) + self.assertIsNone(data["case_name"]) + + def test_distributed_stages_zero_partial_complete_and_not_ready(self): + project = create_project(leader=self.member) + link = create_program_project(self.program, project=project, submitted=True) + self.assertEqual(self.widget()["participant"]["stage"], "submitted") + # Программа создаёт критерий «Комментарий»; здесь изолируем два явных критерия. + self.program.criterias.all().delete() + criteria = [ + Criteria.objects.create( + name=f"C{i}", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + for i in range(2) + ] + experts = [create_rate_expert(program=self.program) for _ in range(2)] + for expert in experts: + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=project, expert=expert.expert + ) + self.assertEqual(self.widget()["participant"]["stage"], "review") + for criterion in criteria: + ProjectScore.objects.create( + project=project, user=experts[0], criteria=criterion, value="0" + ) + self.assertEqual(self.widget()["participant"]["stage"], "review") + self.assertEqual(self.widget(experts[0])["expert"]["remaining"], 0) + ProjectScore.objects.create( + project=project, user=experts[1], criteria=criteria[0], value="0" + ) + self.assertEqual(self.widget(experts[1])["expert"]["remaining"], 1) + ProjectScore.objects.create( + project=project, user=experts[1], criteria=criteria[1], value="0" + ) + self.assertEqual(self.widget(self.member)["participant"]["stage"], "evaluated") + link.submitted = False + link.save(update_fields=["submitted"]) + self.assertEqual(self.widget(experts[0])["expert"]["remaining"], 1) + self.assertEqual( + self.widget(self.member)["participant"]["stage"], "not_submitted" + ) + + def test_open_mode_uses_existing_project_completion_without_invented_assignments( + self, + ): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + expert = create_rate_expert(program=self.program) + project = create_project(leader=self.member) + create_program_project(self.program, project=project, submitted=True) + criterion = Criteria.objects.create( + name="C", type="int", partner_program=self.program + ) + ProjectScore.objects.create( + project=project, criteria=criterion, user=expert, value="0" + ) + self.assertEqual(self.widget()["participant"]["stage"], "evaluated") + data = self.widget(expert)["expert"] + self.assertEqual(data["mode"], "open") + self.assertIsNone(data["assigned"]) + self.assertIsNone(data["remaining"]) + + def test_manager_metrics_match_overview_including_draft_team_and_other_program(self): + manager = create_user() + self.program.managers.add(manager) + teammate = create_user() + create_program_member(self.program, user=teammate) + project = create_project(leader=self.member, draft=True) + create_program_project(self.program, project=project) + Collaborator.objects.create(project=project, user=teammate, role="Participant") + without_project = create_user() + create_program_member(self.program, user=without_project) + create_program_project( + create_partner_program(), project=create_project(leader=without_project) + ) + data = self.widget(manager)["organizer"] + overview = self.client.get( + reverse( + "partner_programs:project-analytics", + kwargs={"program_id": self.program.pk}, + ) + ).data + self.assertEqual(data["participants"], 3) + self.assertEqual(data["projects"], 1) + self.assertEqual(data["participants_without_project"], 1) + self.assertEqual( + data["participants"], overview["summary"]["participants"]["total"] + ) + self.assertEqual(data["projects"], overview["summary"]["projects"]["total"]) + self.assertEqual( + data["submitted_solutions"], overview["solution_funnel"]["submitted"] + ) + self.assertEqual( + data["participants_without_project"], + overview["attention"]["participants_without_team"], + ) + + def test_noncompetitive_metrics_are_inapplicable(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.assertEqual(self.widget()["participant"]["stage"], "not_applicable") + self.program.managers.add(self.member) + self.assertIsNone(self.widget()["organizer"]["submitted_solutions"]) + + def test_get_has_bounded_queries_and_no_business_writes(self): + self.program.managers.add(self.member) + with CaptureQueriesContext(connection) as queries: + self.widget() + self.assertLessEqual(len(queries), 5) + self.assertFalse( + any( + q["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")) + for q in queries + ) + ) + for _ in range(12): + create_program_project(self.program) + with CaptureQueriesContext(connection) as larger_queries: + self.widget() + self.assertEqual(len(queries), len(larger_queries)) + + def test_duplicate_registration_is_rejected_and_users_are_counted_once(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + create_program_member(self.program, user=self.member) + self.program.managers.add(self.member) + self.assertEqual(self.widget()["organizer"]["participants"], 1) + + def test_expert_flag_does_not_expose_member_only_detail(self): + expert = create_rate_expert(program=self.program) + self.client.force_authenticate(expert) + response = self.client.get(f"/programs/{self.program.pk}/") + self.assertTrue(response.data["is_user_expert"]) + self.assertFalse(response.data["is_user_member"]) + self.assertNotIn("description", response.data) + self.assertNotIn("links", response.data) + + def test_other_program_assignment_and_submission_deadline_are_not_used(self): + from django.utils import timezone + + self.program.datetime_evaluation_ends = timezone.now() - timezone.timedelta( + hours=1 + ) + self.program.save(update_fields=["datetime_evaluation_ends"]) + other = create_partner_program() + expert = create_rate_expert(program=self.program) + expert.expert.programs.add(other) + link = create_program_project(other, submitted=True) + ProjectExpertAssignment.objects.create( + partner_program=other, project=link.project, expert=expert.expert + ) + data = self.widget(expert)["expert"] + self.assertEqual(data["assigned"], 0) + self.assertEqual(data["remaining"], 0) + self.assertIsNotNone(data["evaluation_ends"]) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 0ea3c42e..6d724e48 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -46,9 +46,16 @@ ProgramProjectFilterAPIView, ) +from partner_programs.widget_views import ProgramRoleWidgetAPIView + app_name = "partner_programs" urlpatterns = [ + path( + "/analytics-widget/", + ProgramRoleWidgetAPIView.as_view(), + name="analytics-widget", + ), path("", PartnerProgramList.as_view()), path( "/project-analytics/", diff --git a/partner_programs/views.py b/partner_programs/views.py index 51cf771b..7cb99e96 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -137,6 +137,10 @@ def get(self, request, *args, **kwargs): data = serializer.data data["is_user_member"] = is_user_member data["current_project_application"] = current_project_application + data["is_user_expert"] = bool( + request.user.is_authenticated + and program.experts.filter(user_id=request.user.pk).exists() + ) if request.user.is_authenticated: add_view(program, request.user) return Response(data, status=status.HTTP_200_OK) diff --git a/partner_programs/widget_views.py b/partner_programs/widget_views.py new file mode 100644 index 00000000..b6d66b6f --- /dev/null +++ b/partner_programs/widget_views.py @@ -0,0 +1,23 @@ +"""Read-only API Angular-виджета, отдельно от manager-only и React endpoints.""" + +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import PartnerProgram +from partner_programs.serializers.role_analytics import ProgramRoleWidgetSerializer +from partner_programs.services.role_analytics import build_program_role_widget + + +class ProgramRoleWidgetAPIView(APIView): + """Отдельный безопасный GET-контракт без доступа к менеджерской аналитике.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + """Определяет роль по серверным связям и сериализует только её метрики.""" + program = get_object_or_404(PartnerProgram, pk=pk) + # role/user_id/project_id клиента не используются для доступа или выбора. + data = build_program_role_widget(program, request.user) + return Response(ProgramRoleWidgetSerializer(data).data) diff --git a/projects/cover_reset.py b/projects/cover_reset.py new file mode 100644 index 00000000..c8d6eaa4 --- /dev/null +++ b/projects/cover_reset.py @@ -0,0 +1,85 @@ +"""Сброс обложки legacy-проекта без изменения глобальных прав на файлы.""" + +import logging +from functools import partial + +from django.db import transaction +from django.db.models import Q +from rest_framework.exceptions import APIException + +from files.models import UserFile +from files.service import CDN, SelectelSwiftStorage +from projects.models import DefaultProjectAvatar, DefaultProjectCover, Project + +logger = logging.getLogger(__name__) +cdn = CDN(storage=SelectelSwiftStorage()) + + +class DefaultCoverUnavailable(APIException): + status_code = 409 + default_detail = { + "code": "default_cover_unavailable", + "detail": "Стандартная обложка временно недоступна.", + } + + +def reset_project_cover(project: Project, user_id: int) -> dict: + """Назначает обложку уже заблокированному и проверенному на запись проекту. + + Вызывается внутри atomic после проверки обычных прав ProjectDetail. + Очистка запускается только после коммита: сбой CDN не должен вернуть + проекту старый URL или представить успешный сброс как ошибку. + """ + old_url = project.cover_image_address + defaults = DefaultProjectCover.objects.filter(image__isnull=False).exclude( + image_id="" + ) + if defaults.filter(image_id=old_url).exists(): + return {"cover_image_address": old_url, "is_default_cover": True} + + default_url = defaults.order_by("?").values_list("image_id", flat=True).first() + if not default_url: + raise DefaultCoverUnavailable() + + project.cover_image_address = default_url + project.save(update_fields=["cover_image_address", "datetime_updated"]) + transaction.on_commit(partial(cleanup_previous_cover, old_url, user_id)) + return {"cover_image_address": default_url, "is_default_cover": True} + + +def cleanup_previous_cover(old_url: str | None, user_id: int) -> None: + """Удаляет только собственный неиспользуемый файл после фиксации сброса. + + Владение проектом не даёт владения UserFile. Системные обложки/аватары + и файлы других проектов сохраняются даже при совпадении владельца. + Исключения и HTTP-ошибки CDN не откатывают смену обложки; URL и текст + исключений не попадают в лог, поскольку могут содержать закрытые данные. + """ + if not old_url: + return + try: + with transaction.atomic(): + old_file = ( + UserFile.objects.select_for_update() + .filter(link=old_url, user_id=user_id) + .first() + ) + if old_file is None: + return + if ( + DefaultProjectCover.objects.filter(image_id=old_url).exists() + or DefaultProjectAvatar.objects.filter(image_id=old_url).exists() + or Project.objects.filter( + Q(cover_image_address=old_url) + | Q(image_address=old_url) + | Q(presentation_address=old_url) + | Q(cover_id=old_url) + ).exists() + ): + return + response = cdn.delete(old_url) + if response.status_code != 404: + response.raise_for_status() + old_file.delete() + except Exception: + logger.warning("Не удалось очистить прежний файл после сброса обложки проекта.") diff --git a/projects/serializers.py b/projects/serializers.py index 3f00c0f2..d0ed1fa3 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -236,6 +236,7 @@ def update(self, instance, validated_data): class ProjectDetailSerializer(serializers.ModelSerializer): + is_default_cover = serializers.SerializerMethodField() achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) collaborators = CollaboratorSerializer( @@ -261,6 +262,23 @@ class ProjectDetailSerializer(serializers.ModelSerializer): implementation_deadline = serializers.DateField(required=False, allow_null=True) trl = serializers.IntegerField(required=False, allow_null=True) + def get_is_default_cover(self, project): + """Сверяет URL с системными обложками, а не с владельцем или доменом. + + Набор живёт только в этом экземпляре сериализатора: один запрос даже + при many=True, без глобального устаревающего кеша и N+1 на новый флаг. + После PUT проверяется фактический URL, назначенный Project.save(). + """ + from projects.models import DefaultProjectCover + + if not hasattr(self, "_default_cover_urls"): + self._default_cover_urls = set( + DefaultProjectCover.objects.filter(image__isnull=False) + .exclude(image_id="") + .values_list("image_id", flat=True) + ) + return project.cover_image_address in self._default_cover_urls + def get_partner_program(self, project): user = getattr(self.context.get("request"), "user", None) link = None @@ -342,6 +360,7 @@ class Meta: "is_public", "cover", "cover_image_address", + "is_default_cover", "actuality", "problem", "target_audience", diff --git a/projects/tests/test_project_cover_reset.py b/projects/tests/test_project_cover_reset.py new file mode 100644 index 00000000..128df9e8 --- /dev/null +++ b/projects/tests/test_project_cover_reset.py @@ -0,0 +1,241 @@ +"""Права legacy-проекта и сохранность файлов при возврате стандартной обложки.""" + +from unittest.mock import Mock, patch + +from django.db import connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from files.models import UserFile +from invites.models import Invite +from project_rates.tests.helpers import create_rate_expert +from projects.cover_reset import cleanup_previous_cover, reset_project_cover +from projects.models import DefaultProjectCover, Project +from projects.serializers import ProjectDetailSerializer +from projects.views import ProjectDetail, ProjectResetCover + +from .helpers import ( + add_program_member, + create_collaborator, + create_partner_program, + create_project, + create_user, + link_project_to_program, +) + + +class ProjectCoverResetTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.project = create_project(is_public=False) + cls.leader = cls.project.leader + cls.other = create_user() + cls.custom = UserFile.objects.create( + link="https://files.example.com/custom.png", user=cls.leader + ) + cls.default = UserFile.objects.create( + link="https://files.example.com/default.png", user=cls.other + ) + DefaultProjectCover.objects.create(image=cls.default) + cls.project.cover_image_address = cls.custom.link + cls.project.save(update_fields=["cover_image_address"]) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.leader) + self.url = f"/projects/{self.project.pk}/reset-cover/" + self.detail_url = f"/projects/{self.project.pk}/" + self.cdn_patch = patch("projects.cover_reset.cdn") + self.cdn = self.cdn_patch.start() + self.addCleanup(self.cdn_patch.stop) + self.cdn.delete.return_value = Mock(status_code=204) + + def reset(self): + with self.captureOnCommitCallbacks(execute=True): + return self.client.post(self.url, {}, format="json") + + def test_custom_reset_commits_before_owned_file_cleanup(self): + def delete_after_switch(url): + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.default.link) + return Mock(status_code=204) + + self.cdn.delete.side_effect = delete_after_switch + response = self.reset() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + response.data, + {"cover_image_address": self.default.link, "is_default_cover": True}, + ) + self.cdn.delete.assert_called_once_with(self.custom.link) + self.assertFalse(UserFile.objects.filter(pk=self.custom.pk).exists()) + self.assertTrue(UserFile.objects.filter(pk=self.default.pk).exists()) + self.assertTrue(self.client.get(self.detail_url).data["is_default_cover"]) + + def test_default_reset_is_idempotent_even_when_file_owned_by_caller(self): + UserFile.objects.filter(pk=self.default.pk).update(user=self.leader) + Project.objects.filter(pk=self.project.pk).update( + cover_image_address=self.default.link + ) + for _ in range(2): + response = self.reset() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["cover_image_address"], self.default.link) + self.cdn.delete.assert_not_called() + self.assertTrue(DefaultProjectCover.objects.filter(image=self.default).exists()) + self.assertTrue(UserFile.objects.filter(pk=self.default.pk).exists()) + + def test_no_available_default_returns_controlled_conflict_without_changes(self): + DefaultProjectCover.objects.all().delete() + DefaultProjectCover.objects.create(image=None) + response = self.reset() + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "default_cover_unavailable") + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.custom.link) + self.cdn.delete.assert_not_called() + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + + def test_unavailable_rows_do_not_hide_an_available_default(self): + DefaultProjectCover.objects.create(image=None) + self.assertEqual(self.reset().status_code, 200) + + def test_foreign_file_is_preserved(self): + UserFile.objects.filter(pk=self.custom.pk).update(user=self.other) + self.assertEqual(self.reset().status_code, 200) + self.cdn.delete.assert_not_called() + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + + def test_missing_old_file_does_not_prevent_reset(self): + self.custom.delete() + self.assertEqual(self.reset().status_code, 200) + self.cdn.delete.assert_not_called() + + def test_file_referenced_by_another_project_is_preserved(self): + other_project = create_project(leader=self.leader) + Project.objects.filter(pk=other_project.pk).update( + cover_image_address=self.custom.link + ) + self.assertEqual(self.reset().status_code, 200) + self.cdn.delete.assert_not_called() + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + + def test_cleanup_network_failure_keeps_success_and_does_not_log_exception(self): + self.cdn.delete.side_effect = RuntimeError("private-url-or-token") + with self.assertLogs("projects.cover_reset", level="WARNING") as logs: + response = self.reset() + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.default.link) + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + self.assertNotIn("private-url-or-token", " ".join(logs.output)) + + def test_cleanup_http_failure_keeps_success_and_file_record(self): + self.cdn.delete.return_value.raise_for_status.side_effect = RuntimeError() + with self.assertLogs("projects.cover_reset", level="WARNING"): + self.assertEqual(self.reset().status_code, 200) + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.default.link) + + def test_cleanup_404_is_idempotent(self): + self.cdn.delete.return_value = Mock(status_code=404) + self.assertEqual(self.reset().status_code, 200) + self.assertFalse(UserFile.objects.filter(pk=self.custom.pk).exists()) + + def test_cleanup_rechecks_system_file_status(self): + DefaultProjectCover.objects.create(image=self.custom) + cleanup_previous_cover(self.custom.link, self.leader.pk) + self.cdn.delete.assert_not_called() + self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + + def test_rollback_never_runs_cleanup(self): + with self.captureOnCommitCallbacks(execute=True) as callbacks: + with self.assertRaises(RuntimeError): + with transaction.atomic(): + reset_project_cover(self.project, self.leader.pk) + raise RuntimeError("rollback") + self.assertEqual(callbacks, []) + self.cdn.delete.assert_not_called() + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.custom.link) + + def test_permissions_are_identical_to_project_update(self): + self.assertIs( + ProjectResetCover.permission_classes, ProjectDetail.permission_classes + ) + collaborator = create_collaborator(self.project).user + invited = create_user() + Invite.objects.create(project=self.project, user=invited) + program = create_partner_program() + link_project_to_program(self.project, program) + manager = create_user() + program.managers.add(manager) + expert = create_rate_expert(program=program) + for draft in (True, False): + for user in (self.leader, collaborator, invited, manager, expert, self.other): + with self.subTest(draft=draft, user=user.pk): + Project.objects.filter(pk=self.project.pk).update(draft=draft) + self.client.force_authenticate(user) + expected = ( + 200 + if user == self.leader + or (draft and user in (collaborator, invited)) + else 403 + ) + update = self.client.put( + self.detail_url, + {"name": "Проверка прав", "draft": draft}, + format="json", + ) + self.assertEqual(update.status_code, expected, update.data) + reset = self.reset() + self.assertEqual(reset.status_code, expected, reset.data) + + def test_program_timing_restriction_remains_in_force(self): + program = create_partner_program(finished=True) + add_program_member(program, self.leader, project=self.project) + self.assertEqual(self.reset().status_code, 403) + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.custom.link) + + def test_anonymous_and_missing_project(self): + self.client.force_authenticate(None) + self.assertIn(self.reset().status_code, (401, 403)) + self.client.force_authenticate(self.leader) + self.assertEqual( + self.client.post("/projects/99999999/reset-cover/").status_code, 404 + ) + + def test_global_delete_foreign_file_still_forbidden(self): + response = self.client.delete(f"/files/?link={self.default.link}") + self.assertEqual(response.status_code, 403) + self.assertTrue(UserFile.objects.filter(pk=self.default.pk).exists()) + + def test_detail_flag_tracks_actual_url_and_is_read_only(self): + self.assertFalse(self.client.get(self.detail_url).data["is_default_cover"]) + response = self.client.put( + self.detail_url, + { + "draft": True, + "cover_image_address": self.default.link, + "is_default_cover": False, + }, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["is_default_cover"]) + Project.objects.filter(pk=self.project.pk).update(cover_image_address=None) + self.assertFalse(self.client.get(self.detail_url).data["is_default_cover"]) + + def test_detail_many_adds_one_default_query_independent_of_list_size(self): + projects = [self.project] + [create_project() for _ in range(4)] + for count in (1, 5): + with self.subTest(count=count), CaptureQueriesContext(connection) as queries: + data = ProjectDetailSerializer(projects[:count], many=True).data + self.assertEqual(len(data), count) + default_queries = [ + q for q in queries if "projects_defaultprojectcover" in q["sql"] + ] + self.assertEqual(len(default_queries), 1) diff --git a/projects/urls.py b/projects/urls.py index 2fa8002f..686842ea 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -25,6 +25,7 @@ ProjectDetail, ProjectList, ProjectRecommendedUsers, + ProjectResetCover, ProjectSubscribe, ProjectSubscribers, ProjectUnsubscribe, @@ -80,6 +81,7 @@ } ) urlpatterns = [ + path("/reset-cover/", ProjectResetCover.as_view(), name="reset-cover"), path("", ProjectList.as_view()), path("/like/", SetLikeOnProject.as_view()), path("/news/", NewsList.as_view()), diff --git a/projects/views.py b/projects/views.py index 6bad51b0..9fbba1ea 100644 --- a/projects/views.py +++ b/projects/views.py @@ -24,6 +24,7 @@ PartnerProgramUserProfile, ) from projects.exceptions import CollaboratorDoesNotExist +from projects.cover_reset import reset_project_cover from projects.filters import ProjectFilter from projects.helpers import ( check_related_fields_update, @@ -201,6 +202,21 @@ def patch(self, request, pk, **kwargs): return super(ProjectDetail, self).patch(request, pk) +class ProjectResetCover(generics.GenericAPIView): + """Команда Angular-редактора с теми же ограничениями, что и запись проекта.""" + + queryset = Project.objects.all() + permission_classes = ProjectDetail.permission_classes + serializer_class = EmptySerializer + + @transaction.atomic + def post(self, request, pk): + """Проверяет права на актуальном проекте и сохраняет обложку до очистки файла.""" + project = get_object_or_404(self.get_queryset().select_for_update(), pk=pk) + self.check_object_permissions(request, project) + return Response(reset_project_cover(project, request.user.id)) + + class ProjectRecommendedUsers(generics.RetrieveAPIView): queryset = Project.objects.all() permission_classes = [IsProjectLeader] @@ -261,7 +277,10 @@ class ProjectCollaborators(generics.GenericAPIView): Project collaborator retrieve/add/delete view """ - permission_classes = [ProjectVisibilityPermission, IsProjectLeaderOrReadOnlyForNonDrafts] + permission_classes = [ + ProjectVisibilityPermission, + IsProjectLeaderOrReadOnlyForNonDrafts, + ] queryset = Project.objects.all() serializer_class = ProjectCollaboratorSerializer @@ -588,7 +607,9 @@ def _copy_collaborators(original_project: Project, new_project: Project) -> None collaborators_to_create: list[Collaborator] = [] leader_collaborator = None - for collaborator in original_project.collaborator_set.select_related("user").all(): + for collaborator in original_project.collaborator_set.select_related( + "user" + ).all(): if collaborator.user_id == leader_id: leader_collaborator = collaborator continue