diff --git a/docs/project-goals-achievements-workspace-api.md b/docs/project-goals-achievements-workspace-api.md new file mode 100644 index 00000000..d8aa5ca5 --- /dev/null +++ b/docs/project-goals-achievements-workspace-api.md @@ -0,0 +1,94 @@ +# Project Goals and Achievements Workspace API + +## Назначение + +API дополняет безопасный React workspace отдельным CRUD целей и достижений проекта. +Он не использует `ProjectDetailSerializer` и `check_related_fields_update()`, поэтому +изменение вложенного объекта не запускает массовую замену legacy-связей проекта. + +Модели `ProjectGoal` и `Achievement` остаются прежними. Миграция не требуется. +Legacy endpoints также сохраняют существующий контракт. + +## Endpoints + +| Метод | URL | Назначение | +| --- | --- | --- | +| `GET` | `/projects//workspace/goals/` | Список целей проекта | +| `POST` | `/projects//workspace/goals/` | Создание цели | +| `PATCH` | `/projects//workspace/goals//` | Частичное изменение цели | +| `DELETE` | `/projects//workspace/goals//` | Удаление цели | +| `GET` | `/projects//workspace/achievements/` | Список достижений проекта | +| `POST` | `/projects//workspace/achievements/` | Создание достижения | +| `PATCH` | `/projects//workspace/achievements//` | Частичное изменение достижения | +| `DELETE` | `/projects//workspace/achievements//` | Удаление достижения | + +Все endpoints требуют аутентификацию. + +## Контракты + +Цель: + +```json +{ + "id": 12, + "title": "Подготовить прототип", + "completion_date": "2026-12-15", + "responsible": 42 +} +``` + +`title` обязателен и после удаления крайних пробелов не может быть пустым. +`completion_date` может быть `null`. `responsible` должен быть руководителем или +участником именно проекта из URL. Поля `project` и `is_done` этот контракт не +принимает. + +Достижение: + +```json +{ + "id": 7, + "title": "Победа в конкурсе", + "year": 2025 +} +``` + +`title` обязателен и не может состоять из пробелов. `year` — целое число от 2000 +до текущего года включительно. Модель исторически хранит это значение в строковом +поле `Achievement.status`; workspace serializer выполняет явное преобразование, +не меняя legacy-модель и API. + +## Доступ и изоляция + +| Пользователь | Публичный опубликованный проект | Свой private/draft | Чужой private/draft | Изменение | +| --- | --- | --- | --- | --- | +| Руководитель | чтение | чтение | нет | свой проект | +| Collaborator | чтение | чтение своего проекта | нет | нет | +| Авторизованный посторонний | чтение | нет | нет | нет | +| Staff/superuser | чтение | чтение | чтение | административное | + +Видимость совпадает с `GET /projects//workspace/`: публичным считается только +проект с `draft=false` и `is_public=true`. Недоступный private/draft проект скрыт +ответом `404`; видимый пользователю проект без права изменения возвращает `403` +для `POST`, `PATCH` и `DELETE`. + +Идентификатор проекта не принимается из request body. Queryset вложенного объекта +всегда одновременно фильтруется по `project_id` из URL и по собственному ID, +поэтому цель или достижение нельзя прочитать, изменить либо удалить через URL +другого проекта. + +## Транзакции и производительность + +Создание, изменение и удаление выполняются внутри `transaction.atomic`. Сначала +валидируется весь payload, затем меняется одна строка; ошибка в одном поле не +оставляет частично сохраненные значения других полей. + +Список целей загружает ответственных через `select_related`. Контракты списков не +выполняют запрос на каждый элемент и покрыты regression-тестом на постоянный +query budget. + +## Что не входит в этот этап + +API не меняет основные поля, правила публикации и черновиков Project, загрузку +файлов, вакансии, партнеров, ресурсы, приглашения, подписки, конкурсные связи, +Application или Submission lifecycle. Управление выполнением цели (`is_done`) +остается в legacy-контуре до отдельного продуктового решения. diff --git a/projects/tests/test_project_workspace_content_api.py b/projects/tests/test_project_workspace_content_api.py new file mode 100644 index 00000000..01c50873 --- /dev/null +++ b/projects/tests/test_project_workspace_content_api.py @@ -0,0 +1,368 @@ +from datetime import date + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from projects.models import Achievement, ProjectGoal +from projects.tests.helpers import ( + create_collaborator, + create_project, + create_project_goal, + create_user, +) + + +class ProjectWorkspaceContentAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-content-leader") + self.collaborator = create_user(prefix="workspace-content-collaborator") + self.outsider = create_user(prefix="workspace-content-outsider") + self.staff = create_user(prefix="workspace-content-staff") + self.staff.is_staff = True + self.staff.save(update_fields=["is_staff"]) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + create_collaborator(self.project, user=self.collaborator) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def goals_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/goals/" + + def goal_url(self, goal, project=None): + project = project or self.project + return f"{self.goals_url(project)}{goal.pk}/" + + def achievements_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/achievements/" + + def achievement_url(self, achievement, project=None): + project = project or self.project + return f"{self.achievements_url(project)}{achievement.pk}/" + + def test_workspace_content_requires_authentication(self): + self.assertEqual(self.client.get(self.goals_url()).status_code, 401) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 401) + + def test_leader_can_create_list_update_and_delete_goal(self): + self.authenticate(self.leader) + + create_response = self.client.post( + self.goals_url(), + { + "title": " Подготовить прототип ", + "completion_date": "2026-12-15", + "responsible": self.collaborator.pk, + }, + format="json", + ) + + self.assertEqual(create_response.status_code, 201) + goal = ProjectGoal.objects.get(pk=create_response.data["id"]) + self.assertEqual(goal.project, self.project) + self.assertEqual(goal.title, "Подготовить прототип") + self.assertEqual(goal.responsible, self.collaborator) + + list_response = self.client.get(self.goals_url()) + self.assertEqual(list_response.status_code, 200) + self.assertEqual( + list_response.data, + [ + { + "id": goal.pk, + "title": "Подготовить прототип", + "completion_date": "2026-12-15", + "responsible": self.collaborator.pk, + } + ], + ) + + update_response = self.client.patch( + self.goal_url(goal), + {"title": "Провести пилот", "responsible": self.leader.pk}, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + goal.refresh_from_db() + self.assertEqual(goal.title, "Провести пилот") + self.assertEqual(goal.responsible, self.leader) + + delete_response = self.client.delete(self.goal_url(goal)) + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(ProjectGoal.objects.filter(pk=goal.pk).exists()) + + def test_collaborator_can_read_private_project_but_cannot_change_goal(self): + goal = create_project_goal(self.project, responsible=self.collaborator) + Achievement.objects.create( + project=self.project, + title="Командное достижение", + status="2024", + ) + self.authenticate(self.collaborator) + + self.assertEqual(self.client.get(self.goals_url()).status_code, 200) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 200) + create_response = self.client.post( + self.goals_url(), + {"title": "Новая цель", "responsible": self.collaborator.pk}, + format="json", + ) + update_response = self.client.patch( + self.goal_url(goal), + {"title": "Измененная цель"}, + format="json", + ) + delete_response = self.client.delete(self.goal_url(goal)) + + self.assertEqual(create_response.status_code, 403) + self.assertEqual(update_response.status_code, 403) + self.assertEqual(delete_response.status_code, 403) + + def test_outsider_cannot_see_private_content_but_can_read_published_content(self): + create_project_goal(self.project, responsible=self.leader) + public_project = create_project( + leader=self.leader, + draft=False, + is_public=True, + ) + public_goal = create_project_goal(public_project, responsible=self.leader) + self.authenticate(self.outsider) + + self.assertEqual(self.client.get(self.goals_url()).status_code, 404) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 404) + public_response = self.client.get(self.goals_url(public_project)) + self.assertEqual(public_response.status_code, 200) + self.assertEqual(public_response.data[0]["id"], public_goal.pk) + public_write_response = self.client.post( + self.goals_url(public_project), + {"title": "Чужая цель", "responsible": self.leader.pk}, + format="json", + ) + self.assertEqual(public_write_response.status_code, 403) + + def test_responsible_must_belong_to_current_project(self): + other_project = create_project() + other_collaborator = create_collaborator(other_project).user + self.authenticate(self.leader) + + for invalid_user in (self.outsider, other_project.leader, other_collaborator): + with self.subTest(user_id=invalid_user.pk): + response = self.client.post( + self.goals_url(), + {"title": "Недопустимая цель", "responsible": invalid_user.pk}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("responsible", response.data) + + self.assertFalse(ProjectGoal.objects.filter(project=self.project).exists()) + + def test_goal_rejects_whitespace_title(self): + self.authenticate(self.leader) + + response = self.client.post( + self.goals_url(), + {"title": " \t", "responsible": self.leader.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("title", response.data) + + def test_goal_cannot_be_changed_through_another_project_url(self): + goal = create_project_goal(self.project, responsible=self.leader) + other_project = create_project(leader=self.leader) + self.authenticate(self.leader) + + patch_response = self.client.patch( + self.goal_url(goal, other_project), + {"title": "Чужой URL"}, + format="json", + ) + delete_response = self.client.delete(self.goal_url(goal, other_project)) + + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + list_response = self.client.get(self.goals_url(other_project)) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data, []) + goal.refresh_from_db() + self.assertNotEqual(goal.title, "Чужой URL") + + def test_failed_goal_update_does_not_apply_other_fields(self): + goal = create_project_goal( + self.project, + responsible=self.leader, + title="Исходная цель", + ) + original_title = goal.title + self.authenticate(self.leader) + + response = self.client.patch( + self.goal_url(goal), + {"title": "Не сохранять", "responsible": self.outsider.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + goal.refresh_from_db() + self.assertEqual(goal.title, original_title) + self.assertEqual(goal.responsible, self.leader) + + def test_staff_retains_administrative_write_access(self): + self.authenticate(self.staff) + + goal_response = self.client.post( + self.goals_url(), + {"title": "Административная цель", "responsible": self.leader.pk}, + format="json", + ) + achievement_response = self.client.post( + self.achievements_url(), + {"title": "Административное достижение", "year": 2020}, + format="json", + ) + + self.assertEqual(goal_response.status_code, 201) + self.assertEqual(achievement_response.status_code, 201) + + def test_achievement_full_crud_maps_year_to_legacy_status(self): + self.authenticate(self.leader) + + create_response = self.client.post( + self.achievements_url(), + {"title": " Победа в конкурсе ", "year": 2024}, + format="json", + ) + self.assertEqual(create_response.status_code, 201) + self.assertEqual(create_response.data["year"], 2024) + achievement = Achievement.objects.get(pk=create_response.data["id"]) + self.assertEqual(achievement.project, self.project) + self.assertEqual(achievement.title, "Победа в конкурсе") + self.assertEqual(achievement.status, "2024") + + list_response = self.client.get(self.achievements_url()) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data[0]["year"], 2024) + + update_response = self.client.patch( + self.achievement_url(achievement), + {"title": "Финал конкурса", "year": 2025}, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + achievement.refresh_from_db() + self.assertEqual(achievement.title, "Финал конкурса") + self.assertEqual(achievement.status, "2025") + + delete_response = self.client.delete(self.achievement_url(achievement)) + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(Achievement.objects.filter(pk=achievement.pk).exists()) + + def test_achievement_rejects_invalid_year_and_whitespace_title(self): + self.authenticate(self.leader) + + payloads = ( + {"title": "Слишком рано", "year": 1999}, + {"title": "Слишком поздно", "year": date.today().year + 1}, + {"title": " ", "year": 2024}, + ) + for payload in payloads: + with self.subTest(payload=payload): + response = self.client.post( + self.achievements_url(), + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + + self.assertFalse(Achievement.objects.filter(project=self.project).exists()) + + def test_achievement_cannot_be_changed_through_another_project_url(self): + achievement = Achievement.objects.create( + project=self.project, + title="Достижение", + status="2024", + ) + other_project = create_project(leader=self.leader) + self.authenticate(self.leader) + + response = self.client.patch( + self.achievement_url(achievement, other_project), + {"year": 2025}, + format="json", + ) + delete_response = self.client.delete( + self.achievement_url(achievement, other_project) + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + achievement.refresh_from_db() + self.assertEqual(achievement.status, "2024") + + def test_failed_achievement_update_is_atomic(self): + achievement = Achievement.objects.create( + project=self.project, + title="Исходное достижение", + status="2024", + ) + self.authenticate(self.leader) + + response = self.client.patch( + self.achievement_url(achievement), + {"title": "Не сохранять", "year": 1999}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + achievement.refresh_from_db() + self.assertEqual(achievement.title, "Исходное достижение") + self.assertEqual(achievement.status, "2024") + + def test_workspace_lists_do_not_create_n_plus_one_queries(self): + for index in range(5): + participant = create_user(prefix=f"goal-responsible-{index}") + create_collaborator(self.project, user=participant) + create_project_goal(self.project, responsible=participant) + Achievement.objects.create( + project=self.project, + title=f"Достижение {index}", + status=str(2020 + index), + ) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as goal_queries: + goal_response = self.client.get(self.goals_url()) + with CaptureQueriesContext(connection) as achievement_queries: + achievement_response = self.client.get(self.achievements_url()) + + self.assertEqual(goal_response.status_code, 200) + self.assertEqual(achievement_response.status_code, 200) + self.assertEqual(len(goal_response.data), 5) + self.assertEqual(len(achievement_response.data), 5) + self.assertLessEqual(len(goal_queries), 3) + self.assertLessEqual(len(achievement_queries), 3) + + def test_legacy_and_workspace_project_endpoints_remain_available(self): + goal = create_project_goal(self.project, responsible=self.leader) + self.authenticate(self.leader) + + legacy_response = self.client.get(f"/projects/{self.project.pk}/goals/") + legacy_achievements_response = self.client.get("/projects/achievements/") + workspace_response = self.client.get(f"/projects/{self.project.pk}/workspace/") + + self.assertEqual(legacy_response.status_code, 200) + self.assertEqual(legacy_response.data[0]["id"], goal.pk) + self.assertEqual(legacy_achievements_response.status_code, 200) + self.assertEqual(workspace_response.status_code, 200) + self.assertEqual(workspace_response.data["id"], self.project.pk) diff --git a/projects/urls.py b/projects/urls.py index 65fb1a35..c9d2f4f0 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -24,6 +24,12 @@ SetLikeOnProject, SwitchLeaderRole, ) +from projects.workspace_content_views import ( + ProjectWorkspaceAchievementDetailView, + ProjectWorkspaceAchievementListView, + ProjectWorkspaceGoalDetailView, + ProjectWorkspaceGoalListView, +) from projects.workspace_views import ( MyProjectsView, ProjectCatalogView, @@ -76,6 +82,26 @@ ProjectWorkspaceDetailView.as_view(), name="workspace-detail", ), + path( + "/workspace/goals/", + ProjectWorkspaceGoalListView.as_view(), + name="workspace-goals", + ), + path( + "/workspace/goals//", + ProjectWorkspaceGoalDetailView.as_view(), + name="workspace-goal-detail", + ), + path( + "/workspace/achievements/", + ProjectWorkspaceAchievementListView.as_view(), + name="workspace-achievements", + ), + path( + "/workspace/achievements//", + ProjectWorkspaceAchievementDetailView.as_view(), + name="workspace-achievement-detail", + ), path("/like/", SetLikeOnProject.as_view()), path("/news/", NewsList.as_view()), path("/subscribe/", ProjectSubscribe.as_view()), diff --git a/projects/workspace_content_serializers.py b/projects/workspace_content_serializers.py new file mode 100644 index 00000000..c0cede4b --- /dev/null +++ b/projects/workspace_content_serializers.py @@ -0,0 +1,82 @@ +from datetime import date + +from rest_framework import serializers + +from projects.models import Achievement, Collaborator, ProjectGoal + + +class ProjectWorkspaceGoalSerializer(serializers.ModelSerializer): + """Читает и изменяет цель только в контексте проекта из URL.""" + + class Meta: + model = ProjectGoal + fields = ("id", "title", "completion_date", "responsible") + read_only_fields = ("id",) + + def validate_title(self, value): + normalized_title = value.strip() + if not normalized_title: + raise serializers.ValidationError("Укажите название цели.") + return normalized_title + + def validate(self, attrs): + project = self.context["project"] + responsible = attrs.get( + "responsible", + getattr(self.instance, "responsible", None), + ) + if responsible is None: + return attrs + + is_project_member = ( + responsible.pk == project.leader_id + or Collaborator.objects.filter( + project_id=project.pk, + user_id=responsible.pk, + ).exists() + ) + if not is_project_member: + raise serializers.ValidationError( + { + "responsible": ( + "Ответственным может быть только руководитель или участник " + "этого проекта." + ) + } + ) + return attrs + + +class ProjectWorkspaceAchievementSerializer(serializers.ModelSerializer): + """Предоставляет год достижения поверх legacy-поля Achievement.status.""" + + year = serializers.IntegerField( + source="status", + min_value=2000, + max_value=date.today().year, + error_messages={ + "invalid": "Укажите год целым числом.", + "min_value": "Год не может быть раньше 2000.", + "max_value": "Год не может быть позже текущего года.", + }, + ) + + class Meta: + model = Achievement + fields = ("id", "title", "year") + read_only_fields = ("id",) + + def validate_title(self, value): + normalized_title = value.strip() + if not normalized_title: + raise serializers.ValidationError("Укажите название достижения.") + return normalized_title + + def create(self, validated_data): + validated_data["status"] = str(validated_data["status"]) + return super().create(validated_data) + + def update(self, instance, validated_data): + if "status" in validated_data: + validated_data["status"] = str(validated_data["status"]) + return super().update(instance, validated_data) diff --git a/projects/workspace_content_views.py b/projects/workspace_content_views.py new file mode 100644 index 00000000..922de35c --- /dev/null +++ b/projects/workspace_content_views.py @@ -0,0 +1,164 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import generics, mixins +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated + +from projects.models import Achievement, Project, ProjectGoal +from projects.workspace_content_serializers import ( + ProjectWorkspaceAchievementSerializer, + ProjectWorkspaceGoalSerializer, +) +from projects.workspace_selectors import filter_workspace_visible_projects + + +class ProjectWorkspaceNestedObjectMixin: + """Применяет единые workspace-права к вложенным объектам проекта.""" + + permission_classes = (IsAuthenticated,) + project_context_key = "project" + + def get_project(self): + if not hasattr(self, "_workspace_project"): + projects = Project.objects.only( + "id", + "leader_id", + "draft", + "is_public", + ) + projects = filter_workspace_visible_projects( + projects, + user=self.request.user, + ) + self._workspace_project = get_object_or_404( + projects, + pk=self.kwargs["project_id"], + ) + return self._workspace_project + + def check_workspace_write_access(self): + project = self.get_project() + user = self.request.user + if not (user.is_staff or user.is_superuser or project.leader_id == user.pk): + raise PermissionDenied("Редактировать проект может только руководитель.") + + def get_serializer_context(self): + context = super().get_serializer_context() + context[self.project_context_key] = self.get_project() + return context + + +class ProjectWorkspaceGoalListView( + ProjectWorkspaceNestedObjectMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + """Возвращает цели проекта и создает цель для руководителя или staff.""" + + serializer_class = ProjectWorkspaceGoalSerializer + + def get_queryset(self): + return ( + ProjectGoal.objects.filter(project=self.get_project()) + .select_related("responsible") + .order_by("id") + ) + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @transaction.atomic + def post(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(project=self.get_project()) + + +class ProjectWorkspaceGoalDetailView( + ProjectWorkspaceNestedObjectMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + """Изменяет или удаляет цель, принадлежащую проекту из URL.""" + + serializer_class = ProjectWorkspaceGoalSerializer + + def get_queryset(self): + return ProjectGoal.objects.filter(project=self.get_project()).select_related( + "responsible" + ) + + def get_object(self): + return get_object_or_404( + self.get_queryset(), + pk=self.kwargs["goal_id"], + ) + + @transaction.atomic + def patch(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.partial_update(request, *args, **kwargs) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.destroy(request, *args, **kwargs) + + +class ProjectWorkspaceAchievementListView( + ProjectWorkspaceNestedObjectMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + """Возвращает достижения и создает их внутри текущего проекта.""" + + serializer_class = ProjectWorkspaceAchievementSerializer + + def get_queryset(self): + return Achievement.objects.filter(project=self.get_project()).order_by("id") + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @transaction.atomic + def post(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(project=self.get_project()) + + +class ProjectWorkspaceAchievementDetailView( + ProjectWorkspaceNestedObjectMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + """Изменяет или удаляет достижение только в рамках проекта из URL.""" + + serializer_class = ProjectWorkspaceAchievementSerializer + + def get_queryset(self): + return Achievement.objects.filter(project=self.get_project()) + + def get_object(self): + return get_object_or_404( + self.get_queryset(), + pk=self.kwargs["achievement_id"], + ) + + @transaction.atomic + def patch(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.partial_update(request, *args, **kwargs) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.destroy(request, *args, **kwargs) diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py index d010b6aa..181cebaa 100644 --- a/projects/workspace_selectors.py +++ b/projects/workspace_selectors.py @@ -58,3 +58,12 @@ def get_workspace_project_queryset(*, user): ), "links", ) + + +def filter_workspace_visible_projects(queryset: QuerySet[Project], *, user): + """Ограничивает queryset действующими правилами видимости workspace.""" + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter( + Q(draft=False, is_public=True) | Q(leader=user) | Q(collaborator__user=user) + ).distinct() diff --git a/projects/workspace_views.py b/projects/workspace_views.py index b5230c4c..feffbd1d 100644 --- a/projects/workspace_views.py +++ b/projects/workspace_views.py @@ -1,4 +1,3 @@ -from django.db.models import Q from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.exceptions import PermissionDenied, ValidationError @@ -8,6 +7,7 @@ from projects.pagination import ProjectsPagination from projects.workspace_selectors import ( + filter_workspace_visible_projects, get_project_catalog_queryset, get_user_projects_queryset, get_workspace_project_queryset, @@ -92,12 +92,7 @@ class ProjectWorkspaceDetailView(APIView): def get_object(self, request, project_id): queryset = get_workspace_project_queryset(user=request.user) - if not (request.user.is_staff or request.user.is_superuser): - queryset = queryset.filter( - Q(draft=False, is_public=True) - | Q(leader=request.user) - | Q(collaborator__user=request.user) - ).distinct() + queryset = filter_workspace_visible_projects(queryset, user=request.user) return get_object_or_404(queryset, pk=project_id) def get(self, request, project_id):