Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions docs/project-default-cover-reset.md
Original file line number Diff line number Diff line change
@@ -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 не выполнялись.
85 changes: 85 additions & 0 deletions projects/cover_reset.py
Original file line number Diff line number Diff line change
@@ -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("Не удалось очистить прежний файл после сброса обложки проекта.")
19 changes: 19 additions & 0 deletions projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,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(
Expand All @@ -260,6 +261,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):
"""Keep the legacy singular contract: choose the earliest link by pk."""
link = (
Expand Down Expand Up @@ -322,6 +340,7 @@ class Meta:
"is_public",
"cover",
"cover_image_address",
"is_default_cover",
"actuality",
"problem",
"target_audience",
Expand Down
Loading
Loading