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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
from .page import urlpatterns as page_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -28,4 +29,5 @@
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*page_patterns,
]
73 changes: 73 additions & 0 deletions apps/api/plane/api/urls/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file in the repository root for details.

from django.urls import path

from plane.api.views import (
PageDuplicateEndpoint,
PageFavoriteViewSet,
PageVersionEndpoint,
PageViewSet,
PagesDescriptionViewSet,
)

urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
PageViewSet.as_view({"get": "summary"}),
name="api-project-pages-summary",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view({"get": "list", "post": "create"}),
name="api-project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
PageViewSet.as_view(
{"get": "retrieve", "put": "partial_update", "patch": "partial_update", "delete": "destroy"}
),
name="api-project-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:page_id>/",
PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}),
name="api-user-favorite-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
PageViewSet.as_view({"post": "archive", "delete": "unarchive"}),
name="api-project-page-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
PageViewSet.as_view({"post": "lock", "delete": "unlock"}),
name="api-project-page-lock-unlock",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/access/",
PageViewSet.as_view({"post": "access"}),
name="api-project-page-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/description/",
PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="api-page-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/",
PageVersionEndpoint.as_view(),
name="api-page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/<uuid:pk>/",
PageVersionEndpoint.as_view(),
name="api-page-version",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="api-page-duplicate",
),
]
8 changes: 8 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,11 @@
from .invite import WorkspaceInvitationsViewset

from .sticky import StickyViewSet

from .page import (
PageDuplicateEndpoint,
PageFavoriteViewSet,
PageVersionEndpoint,
PageViewSet,
PagesDescriptionViewSet,
)
68 changes: 68 additions & 0 deletions apps/api/plane/api/views/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""API-key authenticated Page endpoints for the public v1 API."""

from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.app.serializers import PageSerializer
from plane.app.views.page.base import (
PageDuplicateEndpoint as AppPageDuplicateEndpoint,
PageFavoriteViewSet as AppPageFavoriteViewSet,
PageViewSet as AppPageViewSet,
PagesDescriptionViewSet as AppPagesDescriptionViewSet,
)
from plane.app.views.page.version import PageVersionEndpoint as AppPageVersionEndpoint
from plane.db.models import Project, ProjectMember


class PageViewSet(AppPageViewSet):
"""Expose the existing Page CRUD handlers through API-key authentication."""

authentication_classes = [APIKeyAuthentication]

def list(self, request, slug, project_id):
"""Return project pages in the v1 cursor-pagination envelope."""
queryset = self.get_queryset()
project = Project.objects.get(pk=project_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
):
queryset = queryset.filter(owned_by=request.user)

return self.paginate(
request=request,
queryset=queryset,
on_results=lambda pages: PageSerializer(pages, many=True).data,
)


class PageFavoriteViewSet(AppPageFavoriteViewSet):
"""Expose Page favorite handlers through API-key authentication."""

authentication_classes = [APIKeyAuthentication]


class PagesDescriptionViewSet(AppPagesDescriptionViewSet):
"""Expose Page description handlers through API-key authentication."""

authentication_classes = [APIKeyAuthentication]


class PageVersionEndpoint(AppPageVersionEndpoint):
"""Expose Page version handlers through API-key authentication."""

authentication_classes = [APIKeyAuthentication]


class PageDuplicateEndpoint(AppPageDuplicateEndpoint):
"""Expose Page duplication through API-key authentication."""

authentication_classes = [APIKeyAuthentication]
122 changes: 122 additions & 0 deletions apps/api/plane/tests/contract/api/test_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file in the repository root for details.

from unittest.mock import patch
from uuid import uuid4

import pytest
from django.urls import resolve
from rest_framework import status

from plane.db.models import Page, Project, ProjectMember, ProjectPage


def _project_url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/"


@pytest.fixture
def page_project(workspace, create_user):
project = Project.objects.create(
name="Page API Project",
identifier=f"P{uuid4().hex[:5].upper()}",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
)
return project


@pytest.mark.contract
def test_page_v1_routes_are_registered():
project_id = uuid4()
page_id = uuid4()

base = _project_url("workspace", project_id)
urls = [
f"{base}pages-summary/",
f"{base}pages/",
f"{base}pages/{page_id}/",
f"{base}favorite-pages/{page_id}/",
f"{base}pages/{page_id}/archive/",
f"{base}pages/{page_id}/lock/",
f"{base}pages/{page_id}/access/",
f"{base}pages/{page_id}/description/",
f"{base}pages/{page_id}/versions/",
f"{base}pages/{page_id}/versions/{uuid4()}/",
f"{base}pages/{page_id}/duplicate/",
]

for url in urls:
assert resolve(url).func.cls.__module__ == "plane.api.views.page"


@pytest.mark.contract
def test_page_v1_rejects_requests_without_api_key(api_client):
url = f"{_project_url('workspace', uuid4())}pages/"

response = api_client.get(url)

assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)


@pytest.mark.contract
@pytest.mark.django_db
def test_page_v1_accepts_api_key(api_key_client, workspace, page_project):
project = page_project
response = api_key_client.get(f"{_project_url(workspace.slug, project.id)}pages/")

assert response.status_code == status.HTTP_200_OK
payload = response.json()
assert payload["results"] == []
assert payload["total_count"] == 0
assert payload["next_page_results"] is False


@pytest.mark.contract
@pytest.mark.django_db
def test_existing_session_page_route_remains_available(session_client, workspace, page_project):
url = f"/api/workspaces/{workspace.slug}/projects/{page_project.id}/pages/"

response = session_client.get(url)

assert response.status_code == status.HTTP_200_OK
assert response.json() == []


@pytest.mark.contract
@pytest.mark.django_db
def test_page_v1_supports_create_list_and_put_update(api_key_client, workspace, page_project):
pages_url = f"{_project_url(workspace.slug, page_project.id)}pages/"

with patch("plane.app.views.page.base.page_transaction.delay"):
create_response = api_key_client.post(
pages_url,
{"name": "API Page", "description_html": "<p>Initial body</p>"},
format="json",
)

assert create_response.status_code == status.HTTP_201_CREATED, create_response.data
page_id = create_response.data["id"]
page = Page.objects.get(id=page_id)
assert ProjectPage.objects.filter(project=page_project, page=page, deleted_at__isnull=True).exists()

list_response = api_key_client.get(pages_url)
assert list_response.status_code == status.HTTP_200_OK
assert [result["id"] for result in list_response.json()["results"]] == [str(page_id)]

with patch("plane.app.views.page.base.page_transaction.delay"):
update_response = api_key_client.put(
f"{pages_url}{page_id}/",
{"name": "Updated API Page"},
format="json",
)

assert update_response.status_code == status.HTTP_200_OK, update_response.data
assert Page.objects.get(id=page_id).name == "Updated API Page"